From 5f98214bce998a5c4854a180ecd04eae6928e5db Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Fri, 4 Sep 2026 13:26:28 +0000 Subject: [PATCH 1/3] fix: mark twaps as completed --- .../handlers/block/orderStatusTracker.ts | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/src/application/handlers/block/orderStatusTracker.ts b/src/application/handlers/block/orderStatusTracker.ts index ed83ab8..776c284 100644 --- a/src/application/handlers/block/orderStatusTracker.ts +++ b/src/application/handlers/block/orderStatusTracker.ts @@ -1,6 +1,6 @@ import { ponder } from "ponder:registry"; -import { conditionalOrderGenerator, discreteOrder } from "ponder:schema"; -import { and, asc, eq, gte, inArray, isNull, lte, notInArray, or, sql } from "ponder"; +import { candidateDiscreteOrder, conditionalOrderGenerator, discreteOrder } from "ponder:schema"; +import { and, asc, eq, exists, gte, inArray, isNull, lte, notExists, notInArray, or, sql } from "ponder"; import { REORG_SAFETY_WINDOW_SECONDS, type SupportedChainId } from "../../../data"; import { DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK, @@ -308,4 +308,64 @@ ponder.on("OrderStatusTracker:block", async ({ event, context }) => { expired.map((r) => r.generatorId), event.block.number, ); + + // A deterministic generator is complete once every known part is terminal. + // This also repairs generators whose final part settled before this check existed. + await context.db.sql + .update(conditionalOrderGenerator) + .set({ + status: "Completed", + lastPollResult: "statusTracker:allTerminal", + updatedAtBlock: event.block.number, + }) + .where( + and( + eq(conditionalOrderGenerator.chainId, chainId), + eq(conditionalOrderGenerator.status, "Active"), + eq(conditionalOrderGenerator.allCandidatesKnown, true), + exists( + context.db.sql + .select({ orderUid: discreteOrder.orderUid }) + .from(discreteOrder) + .where( + and( + eq(discreteOrder.chainId, chainId), + eq( + discreteOrder.conditionalOrderGeneratorId, + conditionalOrderGenerator.eventId, + ), + ), + ), + ), + notExists( + context.db.sql + .select({ orderUid: discreteOrder.orderUid }) + .from(discreteOrder) + .where( + and( + eq(discreteOrder.chainId, chainId), + eq( + discreteOrder.conditionalOrderGeneratorId, + conditionalOrderGenerator.eventId, + ), + eq(discreteOrder.status, "open"), + ), + ), + ), + notExists( + context.db.sql + .select({ orderUid: candidateDiscreteOrder.orderUid }) + .from(candidateDiscreteOrder) + .where( + and( + eq(candidateDiscreteOrder.chainId, chainId), + eq( + candidateDiscreteOrder.conditionalOrderGeneratorId, + conditionalOrderGenerator.eventId, + ), + ), + ), + ), + ), + ); }); From b049d373f2f78fd1413b35446b66338d67ab4535 Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Fri, 4 Sep 2026 13:51:27 +0000 Subject: [PATCH 2/3] refactor: refresh twap status while refreshing amounts --- .../handlers/block/candidateConfirmer.ts | 18 ++- .../handlers/block/orderStatusTracker.ts | 81 ++-------- src/application/helpers/executedAmounts.ts | 74 +++++++-- src/application/helpers/orderbook/client.ts | 9 +- src/application/helpers/uidPrecompute.ts | 9 +- tests/helpers/executedAmounts.test.ts | 148 +++++++++++++++--- 6 files changed, 230 insertions(+), 109 deletions(-) diff --git a/src/application/handlers/block/candidateConfirmer.ts b/src/application/handlers/block/candidateConfirmer.ts index b964333..96c8daf 100644 --- a/src/application/handlers/block/candidateConfirmer.ts +++ b/src/application/handlers/block/candidateConfirmer.ts @@ -12,7 +12,7 @@ import { withTimeout } from "../../helpers/withTimeout"; import { bumpGeneratorsUpdatedAt } from "../../helpers/updatedAtBlock"; import { log } from "../../helpers/logger"; import { type DiscreteStatus } from "./shared"; -import { refreshTwapExecutedTotals } from "../../helpers/executedAmounts"; +import { refreshTwapExecutionState } from "../../helpers/executedAmounts"; // ─── CandidateConfirmer ────────────────────────────────────────────────────── // Checks if candidate discrete orders exist on the Orderbook API. @@ -142,10 +142,11 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { event.block.number, ); - await refreshTwapExecutedTotals( + await refreshTwapExecutionState( context, chainId, orphanCandidates.map((candidate) => candidate.generatorId), + event.block.number, ); const preflightKnown = preflightStatuses.size; @@ -380,10 +381,15 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { } if (confirmed > 0 || stale.length > 0) { - await refreshTwapExecutedTotals(context, chainId, [ - ...rowsToUpsert.map((row) => row.conditionalOrderGeneratorId), - ...stale.map((candidate) => candidate.generatorId), - ]); + await refreshTwapExecutionState( + context, + chainId, + [ + ...rowsToUpsert.map((row) => row.conditionalOrderGeneratorId), + ...stale.map((candidate) => candidate.generatorId), + ], + event.block.number, + ); log("info", "CandidateConfirmer:DONE", { block: String(event.block.number), chainId, candidates: unconfirmed.length, confirmed, expired: stale.length }); } }); diff --git a/src/application/handlers/block/orderStatusTracker.ts b/src/application/handlers/block/orderStatusTracker.ts index 776c284..267e45a 100644 --- a/src/application/handlers/block/orderStatusTracker.ts +++ b/src/application/handlers/block/orderStatusTracker.ts @@ -1,6 +1,6 @@ import { ponder } from "ponder:registry"; -import { candidateDiscreteOrder, conditionalOrderGenerator, discreteOrder } from "ponder:schema"; -import { and, asc, eq, exists, gte, inArray, isNull, lte, notExists, notInArray, or, sql } from "ponder"; +import { conditionalOrderGenerator, discreteOrder } from "ponder:schema"; +import { and, asc, eq, gte, inArray, isNull, lte, notInArray, or, sql } from "ponder"; import { REORG_SAFETY_WINDOW_SECONDS, type SupportedChainId } from "../../../data"; import { DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK, @@ -9,7 +9,7 @@ import { import { fetchOrderStatusByUids } from "../../helpers/orderbookClient"; import { bumpGeneratorsUpdatedAt } from "../../helpers/updatedAtBlock"; import { log } from "../../helpers/logger"; -import { refreshTwapExecutedTotals } from "../../helpers/executedAmounts"; +import { refreshTwapExecutionState } from "../../helpers/executedAmounts"; const VALID_DISCRETE_STATUSES = new Set(["fulfilled", "unfilled", "expired", "cancelled"]); @@ -108,10 +108,11 @@ ponder.on("OrderStatusTracker:block", async ({ event, context }) => { event.block.number, ); - await refreshTwapExecutedTotals( + await refreshTwapExecutionState( context, chainId, rowsToUpdate.map((row) => row.conditionalOrderGeneratorId), + event.block.number, ); log("info", "OrderStatusTracker:DONE", { block: String(event.block.number), chainId, open: openOrders.length, updated: rowsToUpdate.length }); @@ -249,7 +250,12 @@ ponder.on("OrderStatusTracker:block", async ({ event, context }) => { if (touchedGeneratorIds.length > 0) { await bumpGeneratorsUpdatedAt(context, chainId, touchedGeneratorIds, event.block.number); - await refreshTwapExecutedTotals(context, chainId, touchedGeneratorIds); + await refreshTwapExecutionState( + context, + chainId, + touchedGeneratorIds, + event.block.number, + ); log("info", "OrderStatusTracker:REORG_HEAL", { block: String(event.block.number), chainId, @@ -309,63 +315,10 @@ ponder.on("OrderStatusTracker:block", async ({ event, context }) => { event.block.number, ); - // A deterministic generator is complete once every known part is terminal. - // This also repairs generators whose final part settled before this check existed. - await context.db.sql - .update(conditionalOrderGenerator) - .set({ - status: "Completed", - lastPollResult: "statusTracker:allTerminal", - updatedAtBlock: event.block.number, - }) - .where( - and( - eq(conditionalOrderGenerator.chainId, chainId), - eq(conditionalOrderGenerator.status, "Active"), - eq(conditionalOrderGenerator.allCandidatesKnown, true), - exists( - context.db.sql - .select({ orderUid: discreteOrder.orderUid }) - .from(discreteOrder) - .where( - and( - eq(discreteOrder.chainId, chainId), - eq( - discreteOrder.conditionalOrderGeneratorId, - conditionalOrderGenerator.eventId, - ), - ), - ), - ), - notExists( - context.db.sql - .select({ orderUid: discreteOrder.orderUid }) - .from(discreteOrder) - .where( - and( - eq(discreteOrder.chainId, chainId), - eq( - discreteOrder.conditionalOrderGeneratorId, - conditionalOrderGenerator.eventId, - ), - eq(discreteOrder.status, "open"), - ), - ), - ), - notExists( - context.db.sql - .select({ orderUid: candidateDiscreteOrder.orderUid }) - .from(candidateDiscreteOrder) - .where( - and( - eq(candidateDiscreteOrder.chainId, chainId), - eq( - candidateDiscreteOrder.conditionalOrderGeneratorId, - conditionalOrderGenerator.eventId, - ), - ), - ), - ), - ), - ); + await refreshTwapExecutionState( + context, + chainId, + expired.map((r) => r.generatorId), + event.block.number, + ); }); diff --git a/src/application/helpers/executedAmounts.ts b/src/application/helpers/executedAmounts.ts index 19378a3..d61aa9a 100644 --- a/src/application/helpers/executedAmounts.ts +++ b/src/application/helpers/executedAmounts.ts @@ -1,5 +1,6 @@ import { and, eq, inArray, sql } from "ponder"; import { + candidateDiscreteOrder, conditionalOrderGenerator, discreteOrder, type TwapAdditionalData, @@ -12,23 +13,26 @@ const ZERO_TOTALS: TwapAdditionalData = { executedFee: "0", }; -/** Rebuild TWAP parents' execution totals (additionalData) after part-order writes. +/** Rebuild TWAP parents' execution state after part-order writes. * TWAP-only: every part sells the same token, so summing raw amounts is unit-safe * (the orderbook reports executedFee in the sell token for sell orders). Other * order types keep additionalData null — e.g. PerpetualSwap parts alternate * direction, so a single sum would mix token units. */ -export async function refreshTwapExecutedTotals( +export async function refreshTwapExecutionState( context: Context, chainId: number, generatorIds: string[], -): Promise { + blockNumber: bigint, +): Promise { const ids = [...new Set(generatorIds)]; - if (ids.length === 0) return; + if (ids.length === 0) return []; const generators = (await context.db.sql .select({ eventId: conditionalOrderGenerator.eventId, orderType: conditionalOrderGenerator.orderType, + status: conditionalOrderGenerator.status, + allCandidatesKnown: conditionalOrderGenerator.allCandidatesKnown, }) .from(conditionalOrderGenerator) .where( @@ -36,12 +40,17 @@ export async function refreshTwapExecutedTotals( eq(conditionalOrderGenerator.chainId, chainId), inArray(conditionalOrderGenerator.eventId, ids), ), - )) as { eventId: string; orderType: string }[]; + )) as { + eventId: string; + orderType: string; + status: string; + allCandidatesKnown: boolean; + }[]; const twapIds = generators .filter((generator) => generator.orderType === "TWAP") .map((generator) => generator.eventId); - if (twapIds.length === 0) return; + if (twapIds.length === 0) return []; // The .as() aliases are load-bearing: drizzle does not auto-alias raw sql // fragments, so without them all three columns come back named "coalesce". @@ -54,6 +63,8 @@ export async function refreshTwapExecutedTotals( executedSellAmount: sql`coalesce(sum(${discreteOrder.executedSellAmount}), 0)::text`.as("executed_sell_amount_sum"), executedBuyAmount: sql`coalesce(sum(${discreteOrder.executedBuyAmount}), 0)::text`.as("executed_buy_amount_sum"), executedFee: sql`coalesce(sum(${discreteOrder.executedFee}), 0)::text`.as("executed_fee_sum"), + partCount: sql`count(*)::int`.as("part_count"), + openPartCount: sql`count(*) filter (where ${discreteOrder.status} = 'open')::int`.as("open_part_count"), }) .from(discreteOrder) .where( @@ -65,12 +76,55 @@ export async function refreshTwapExecutedTotals( .groupBy(discreteOrder.conditionalOrderGeneratorId); const totalsByGenerator = new Map( - rows.map(({ generatorId, ...totals }) => [generatorId, totals]), + rows.map(({ generatorId, partCount, openPartCount, ...totals }) => [ + generatorId, + { totals, partCount, openPartCount }, + ]), ); - for (const eventId of twapIds) { + const candidateGeneratorIds = new Set( + ( + await context.db.sql + .select({ + generatorId: candidateDiscreteOrder.conditionalOrderGeneratorId, + }) + .from(candidateDiscreteOrder) + .where( + and( + eq(candidateDiscreteOrder.chainId, chainId), + inArray(candidateDiscreteOrder.conditionalOrderGeneratorId, twapIds), + ), + ) + .groupBy(candidateDiscreteOrder.conditionalOrderGeneratorId) + ).map((row) => row.generatorId), + ); + + const completed: string[] = []; + for (const generator of generators) { + if (generator.orderType !== "TWAP") continue; + + const aggregate = totalsByGenerator.get(generator.eventId); + const isComplete = + generator.status === "Active" && + generator.allCandidatesKnown && + aggregate != null && + aggregate.partCount > 0 && + aggregate.openPartCount === 0 && + !candidateGeneratorIds.has(generator.eventId); + await context.db - .update(conditionalOrderGenerator, { chainId, eventId }) - .set({ additionalData: totalsByGenerator.get(eventId) ?? ZERO_TOTALS }); + .update(conditionalOrderGenerator, { chainId, eventId: generator.eventId }) + .set({ + additionalData: aggregate?.totals ?? ZERO_TOTALS, + ...(isComplete && { + status: "Completed" as const, + lastPollResult: "executionState:allTerminal", + updatedAtBlock: blockNumber, + }), + }); + + if (isComplete) completed.push(generator.eventId); } + + return completed; } diff --git a/src/application/helpers/orderbook/client.ts b/src/application/helpers/orderbook/client.ts index 665307e..bbc96e3 100644 --- a/src/application/helpers/orderbook/client.ts +++ b/src/application/helpers/orderbook/client.ts @@ -29,7 +29,7 @@ import { import { TimeoutError, withTimeout } from "../withTimeout"; import { bumpGeneratorsUpdatedAt } from "../updatedAtBlock"; import { log } from "../logger"; -import { refreshTwapExecutedTotals } from "../executedAmounts"; +import { refreshTwapExecutionState } from "../executedAmounts"; import { fetchAccountOrders, fetchOrdersByUids } from "./http"; import { advanceOwnerOffset, @@ -306,7 +306,12 @@ export async function upsertDiscreteOrders( } await bumpGeneratorsUpdatedAt(context, chainId, changedGeneratorIds, blockNumber); - await refreshTwapExecutedTotals(context, chainId, changedGeneratorIds); + await refreshTwapExecutionState( + context, + chainId, + changedGeneratorIds, + blockNumber, + ); return changedCount; } diff --git a/src/application/helpers/uidPrecompute.ts b/src/application/helpers/uidPrecompute.ts index 5aa6dec..8e26987 100644 --- a/src/application/helpers/uidPrecompute.ts +++ b/src/application/helpers/uidPrecompute.ts @@ -24,7 +24,7 @@ import { fetchOrderStatusByUids } from "./orderbookClient"; import { type OrderType, DETERMINISTIC_ORDER_TYPE } from "../../utils/order-types"; import { log } from "./logger"; import { MAX_TWAP_PRECOMPUTE_PARTS } from "../../constants"; -import { refreshTwapExecutedTotals } from "./executedAmounts"; +import { refreshTwapExecutionState } from "./executedAmounts"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -178,7 +178,12 @@ export async function precomputeAndDiscover( } if (discreteRows.length > 0) { - await refreshTwapExecutedTotals(context, chainId, [generatorEventId]); + await refreshTwapExecutionState( + context, + chainId, + [generatorEventId], + blockNumber, + ); } const allTerminal = precomputed.every((o) => { diff --git a/tests/helpers/executedAmounts.test.ts b/tests/helpers/executedAmounts.test.ts index 8cd4876..eeea9e0 100644 --- a/tests/helpers/executedAmounts.test.ts +++ b/tests/helpers/executedAmounts.test.ts @@ -6,6 +6,8 @@ vi.mock("ponder:schema", () => ({ eventId: "eventId", chainId: "chainId", orderType: "orderType", + status: "status", + allCandidatesKnown: "allCandidatesKnown", }, discreteOrder: { conditionalOrderGeneratorId: "conditionalOrderGeneratorId", @@ -13,6 +15,11 @@ vi.mock("ponder:schema", () => ({ executedSellAmount: "executedSellAmount", executedBuyAmount: "executedBuyAmount", executedFee: "executedFee", + status: "status", + }, + candidateDiscreteOrder: { + conditionalOrderGeneratorId: "conditionalOrderGeneratorId", + chainId: "chainId", }, })); @@ -21,25 +28,70 @@ vi.mock("ponder", () => ({ eq: vi.fn(), inArray: vi.fn(), // The aggregate fragments call .as(alias) — required so the three sum columns - // get distinct names (see refreshTwapExecutedTotals). + // get distinct names (see refreshTwapExecutionState). sql: vi.fn(() => ({ as: vi.fn((alias: string) => ({ alias })) })), })); -import { refreshTwapExecutedTotals } from "../../src/application/helpers/executedAmounts"; +import { refreshTwapExecutionState } from "../../src/application/helpers/executedAmounts"; + +function generator( + eventId: string, + overrides: Partial<{ + orderType: string; + status: string; + allCandidatesKnown: boolean; + }> = {}, +) { + return { + eventId, + orderType: "TWAP", + status: "Active", + allCandidatesKnown: true, + ...overrides, + }; +} + +function totals( + generatorId: string, + overrides: Partial<{ partCount: number; openPartCount: number }> = {}, +) { + return { + generatorId, + executedSellAmount: "100", + executedBuyAmount: "90", + executedFee: "2", + partCount: 2, + openPartCount: 1, + ...overrides, + }; +} /** Fake context: the first select resolves the generator-type lookup, the * second (with .groupBy) resolves the per-generator aggregate. */ function makeContext( - generators: { eventId: string; orderType: string }[], + generators: { + eventId: string; + orderType: string; + status: string; + allCandidatesKnown: boolean; + }[], totals: { generatorId: string; executedSellAmount: string; executedBuyAmount: string; executedFee: string; + partCount: number; + openPartCount: number; }[], + candidateGeneratorIds: string[] = [], ) { let selectCalls = 0; - const groupBy = vi.fn().mockResolvedValue(totals); + const groupBy = vi + .fn() + .mockResolvedValueOnce(totals) + .mockResolvedValueOnce( + candidateGeneratorIds.map((generatorId) => ({ generatorId })), + ); const where = vi.fn(() => { selectCalls++; if (selectCalls === 1) return Promise.resolve(generators); @@ -58,28 +110,19 @@ function makeContext( }; } -describe("refreshTwapExecutedTotals", () => { +describe("refreshTwapExecutionState", () => { it("writes totals for TWAP parents and zeros for TWAP parents without parts", async () => { const { context, update, set } = makeContext( - [ - { eventId: "generator-a", orderType: "TWAP" }, - { eventId: "generator-b", orderType: "TWAP" }, - ], - [ - { - generatorId: "generator-a", - executedSellAmount: "100", - executedBuyAmount: "90", - executedFee: "2", - }, - ], + [generator("generator-a"), generator("generator-b")], + [totals("generator-a")], ); - await refreshTwapExecutedTotals(context, 100, [ - "generator-a", - "generator-a", - "generator-b", - ]); + await refreshTwapExecutionState( + context, + 100, + ["generator-a", "generator-a", "generator-b"], + 123n, + ); expect(update).toHaveBeenCalledTimes(2); expect(update).toHaveBeenNthCalledWith(1, expect.anything(), { chainId: 100, eventId: "generator-a" }); @@ -102,11 +145,16 @@ describe("refreshTwapExecutedTotals", () => { it("skips non-TWAP parents entirely", async () => { const { context, select, update } = makeContext( - [{ eventId: "generator-swap", orderType: "PerpetualSwap" }], + [ + generator("generator-swap", { + orderType: "PerpetualSwap", + allCandidatesKnown: false, + }), + ], [], ); - await refreshTwapExecutedTotals(context, 100, ["generator-swap"]); + await refreshTwapExecutionState(context, 100, ["generator-swap"], 123n); expect(select).toHaveBeenCalledTimes(1); // type lookup only, no aggregate expect(update).not.toHaveBeenCalled(); @@ -115,9 +163,59 @@ describe("refreshTwapExecutedTotals", () => { it("does nothing without affected parents", async () => { const { context, select, update } = makeContext([], []); - await refreshTwapExecutedTotals(context, 100, []); + await refreshTwapExecutionState(context, 100, [], 123n); expect(select).not.toHaveBeenCalled(); expect(update).not.toHaveBeenCalled(); }); + + it("completes an active TWAP without open parts or candidates", async () => { + const { context, set } = makeContext( + [generator("generator-a")], + [totals("generator-a", { openPartCount: 0 })], + ); + + const completed = await refreshTwapExecutionState( + context, + 100, + ["generator-a"], + 123n, + ); + + expect(completed).toEqual(["generator-a"]); + expect(set).toHaveBeenCalledWith({ + additionalData: { + executedSellAmount: "100", + executedBuyAmount: "90", + executedFee: "2", + }, + status: "Completed", + lastPollResult: "executionState:allTerminal", + updatedAtBlock: 123n, + }); + }); + + it("keeps a TWAP active while a candidate remains", async () => { + const { context, set } = makeContext( + [generator("generator-a")], + [totals("generator-a", { partCount: 1, openPartCount: 0 })], + ["generator-a"], + ); + + const completed = await refreshTwapExecutionState( + context, + 100, + ["generator-a"], + 123n, + ); + + expect(completed).toEqual([]); + expect(set).toHaveBeenCalledWith({ + additionalData: { + executedSellAmount: "100", + executedBuyAmount: "90", + executedFee: "2", + }, + }); + }); }); From f4a27a7dca1c19058d6fb22a9ea5460c1c1f8e74 Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Tue, 8 Sep 2026 13:41:41 +0000 Subject: [PATCH 3/3] fix: make it reorg safe --- src/application/helpers/executedAmounts.ts | 10 +++++ tests/helpers/executedAmounts.test.ts | 48 ++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/application/helpers/executedAmounts.ts b/src/application/helpers/executedAmounts.ts index d61aa9a..4ba236e 100644 --- a/src/application/helpers/executedAmounts.ts +++ b/src/application/helpers/executedAmounts.ts @@ -111,6 +111,11 @@ export async function refreshTwapExecutionState( aggregate.partCount > 0 && aggregate.openPartCount === 0 && !candidateGeneratorIds.has(generator.eventId); + // Orderbook reorg reconciliation can reopen a previously terminal part. + const isReopened = + generator.status === "Completed" && + ((aggregate?.openPartCount ?? 0) > 0 || + candidateGeneratorIds.has(generator.eventId)); await context.db .update(conditionalOrderGenerator, { chainId, eventId: generator.eventId }) @@ -121,6 +126,11 @@ export async function refreshTwapExecutionState( lastPollResult: "executionState:allTerminal", updatedAtBlock: blockNumber, }), + ...(isReopened && { + status: "Active" as const, + lastPollResult: "executionState:reopened", + updatedAtBlock: blockNumber, + }), }); if (isComplete) completed.push(generator.eventId); diff --git a/tests/helpers/executedAmounts.test.ts b/tests/helpers/executedAmounts.test.ts index eeea9e0..4d8bb56 100644 --- a/tests/helpers/executedAmounts.test.ts +++ b/tests/helpers/executedAmounts.test.ts @@ -111,6 +111,54 @@ function makeContext( } describe("refreshTwapExecutionState", () => { + it("reopens a completed TWAP after a part reverts, then completes it again", async () => { + const parent = generator("generator-a"); + for (const [openPartCount, expectedStatus, blockNumber] of [ + [0, "Completed", 123n], + [1, "Active", 124n], + [0, "Completed", 125n], + ] as const) { + const { context, set } = makeContext( + [parent], + [totals(parent.eventId, { openPartCount })], + ); + const completed = await refreshTwapExecutionState(context, 100, [parent.eventId], blockNumber); + + expect(set).toHaveBeenCalledWith(expect.objectContaining({ + status: expectedStatus, + updatedAtBlock: blockNumber, + lastPollResult: expectedStatus === "Active" + ? "executionState:reopened" + : "executionState:allTerminal", + })); + expect(completed).toEqual(expectedStatus === "Completed" ? [parent.eventId] : []); + parent.status = expectedStatus; + } + }); + + it("reopens a completed TWAP with a remaining candidate", async () => { + const { context, set } = makeContext( + [generator("generator-a", { status: "Completed" })], + [totals("generator-a", { openPartCount: 0 })], + ["generator-a"], + ); + await refreshTwapExecutionState(context, 100, ["generator-a"], 124n); + expect(set).toHaveBeenCalledWith(expect.objectContaining({ + status: "Active", + updatedAtBlock: 124n, + })); + }); + + it.each([0, 1])("preserves a cancelled parent with %i open parts", async (openPartCount) => { + const { context, set } = makeContext( + [generator("generator-a", { status: "Cancelled" })], + [totals("generator-a", { openPartCount })], + ); + const completed = await refreshTwapExecutionState(context, 100, ["generator-a"], 124n); + expect(completed).toEqual([]); + expect(set.mock.calls[0]?.[0]).not.toHaveProperty("status"); + }); + it("writes totals for TWAP parents and zeros for TWAP parents without parts", async () => { const { context, update, set } = makeContext( [generator("generator-a"), generator("generator-b")],