From 74576422e30f52dc12948effa14e30d46430108d Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 16:48:38 -0300 Subject: [PATCH 1/5] fix: account fallback for TWAP parts aged out of /by_uids (COW-989) When /orders/by_uids returns nothing for a stale candidateDiscreteOrder (near or past validTo), fall back to /account/{owner}/orders before defaulting to "expired". Groups missed UIDs by owner so only one account fetch per unique owner is needed. Prevents fulfilled TWAP parts from being recorded as "expired" when the API has aged them out of /by_uids. Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/blockHandler.ts | 44 +++++++++++++++++++++- src/application/helpers/orderbookClient.ts | 24 ++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 817453c..00c0303 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -26,6 +26,7 @@ import { BLOCK_HANDLER_RPC_TIMEOUT_MS, BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS, DEFAULT_MAX_GENERATORS_PER_BLOCK, + ORDERBOOK_HTTP_TIMEOUT_MS, DETERMINISTIC_CANCEL_SWEEP_INTERVAL, RECHECK_INTERVAL, TRY_NEXT_BLOCK_WARMUP_THRESHOLD, @@ -34,7 +35,7 @@ import { TRY_NEXT_BLOCK_BACKOFF_MID, TRY_NEXT_BLOCK_BACKOFF_COLD, } from "../../constants"; -import { fetchComposableOrders, fetchOrderStatusByUids, upsertDiscreteOrders } from "../helpers/orderbookClient"; +import { fetchComposableOrders, fetchOrderStatusByUids, fetchOwnerOrderStatuses, upsertDiscreteOrders } from "../helpers/orderbookClient"; import { TimeoutError, withTimeout } from "../helpers/withTimeout"; import { GET_TRADEABLE_ORDER_WITH_ERRORS_ABI, @@ -514,6 +515,47 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { if (stale.length > 0) { const staleStatuses = await fetchOrderStatusByUids(context, chainId, stale.map((c) => c.orderUid)); + + // TWAP parts can age out of /by_uids before C2 sees them, causing fulfilled + // parts to be recorded as "expired". For any missed UIDs, fall back to + // /account/{owner}/orders — one fetch per unique owner. + const missed = stale.filter((c) => !staleStatuses.has(c.orderUid)); + if (missed.length > 0) { + const generatorIds = [...new Set(missed.map((c) => c.generatorId))]; + const ownerRows = (await context.db.sql + .select({ eventId: conditionalOrderGenerator.eventId, owner: conditionalOrderGenerator.owner }) + .from(conditionalOrderGenerator) + .where(inArray(conditionalOrderGenerator.eventId, generatorIds))) as { + eventId: string; + owner: string; + }[]; + const ownerByGeneratorId = new Map(ownerRows.map((g) => [g.eventId, g.owner as Hex])); + + const missedByOwner = new Map>(); + for (const c of missed) { + const owner = ownerByGeneratorId.get(c.generatorId); + if (!owner) continue; + const ownerKey = owner.toLowerCase() as Hex; + if (!missedByOwner.has(ownerKey)) missedByOwner.set(ownerKey, new Set()); + missedByOwner.get(ownerKey)!.add(c.orderUid); + } + + for (const [owner, ownerMissedUids] of missedByOwner) { + try { + const ownerStatuses = await withTimeout( + fetchOwnerOrderStatuses(chainId, owner), + ORDERBOOK_HTTP_TIMEOUT_MS, + "c2:stale:accountFallback", + ); + for (const [uid, info] of ownerStatuses) { + if (ownerMissedUids.has(uid)) staleStatuses.set(uid, info); + } + } catch { + // Fallback failed — these UIDs will default to "expired" + } + } + } + const staleRows: (typeof discreteOrder.$inferInsert)[] = stale.map((c) => { const entry = staleStatuses.get(c.orderUid); return { diff --git a/src/application/helpers/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index 5ef7b2d..635e6a7 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -294,6 +294,30 @@ export async function fetchOrderStatusByUids( return result; } +/** + * Fallback status lookup via GET /account/{owner}/orders. + * Used when /orders/by_uids returns nothing for UIDs that may have aged out + * of the API's retention window (e.g. TWAP parts near or past validTo). + * Returns a Map of uid -> OrderStatusInfo for all orders found for this owner. + */ +export async function fetchOwnerOrderStatuses( + chainId: number, + owner: Hex, +): Promise> { + const result = new Map(); + const apiBaseUrl = ORDERBOOK_API_URLS[chainId]; + if (!apiBaseUrl) return result; + const orders = await fetchAccountOrders(apiBaseUrl, owner); + for (const order of orders) { + result.set(order.uid, { + status: order.status, + executedSellAmount: order.executedSellAmount, + executedBuyAmount: order.executedBuyAmount, + }); + } + return result; +} + // ─── API calls ─────────────────────────────────────────────────────────────── /** Fetch all orders for an owner with pagination. */ From be1b7679af4edb41175560f5eb192f343679f770 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 18:43:14 -0300 Subject: [PATCH 2/5] test: add fetchOwnerOrderStatuses unit tests (COW-989) Co-Authored-By: Claude Sonnet 4.6 --- tests/helpers/orderbookClient.test.ts | 287 ++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 tests/helpers/orderbookClient.test.ts diff --git a/tests/helpers/orderbookClient.test.ts b/tests/helpers/orderbookClient.test.ts new file mode 100644 index 0000000..ad8cfa9 --- /dev/null +++ b/tests/helpers/orderbookClient.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, vi } from "vitest"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; +import type { Hex } from "viem"; + +// Mock Ponder virtual modules that are not available outside the Ponder runtime. +// vi.mock calls are hoisted by vitest so they resolve before any imports below. +vi.mock("ponder:schema", () => ({ + conditionalOrderGenerator: { $inferInsert: {}, eventId: "eventId", orderType: "orderType", chainId: "chainId", hash: "hash" }, + discreteOrder: { $inferInsert: {}, chainId: "chainId", orderUid: "orderUid" }, +})); + +vi.mock("ponder", () => ({ + and: vi.fn(), + eq: vi.fn(), + sql: Object.assign(vi.fn(), { raw: vi.fn() }), +})); + +// We import the module under test after patching ORDERBOOK_API_URLS via a +// helper that starts a local HTTP server and temporarily overrides the URL. +// Because orderbookClient.ts reads ORDERBOOK_API_URLS at call time (not at +// module load time) we can monkey-patch it for each test. +import * as data from "../../src/data"; +import { fetchOwnerOrderStatuses } from "../../src/application/helpers/orderbookClient"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +interface OrderStub { + uid: string; + status: string; + executedSellAmount: string; + executedBuyAmount: string; + sellAmount?: string; + buyAmount?: string; + feeAmount?: string; + validTo?: number; + creationDate?: string; + signingScheme?: string; + signature?: string; +} + +function makeOrderStub(overrides: Partial & Pick): OrderStub { + return { + sellAmount: "1000000000000000000", + buyAmount: "2000000000", + feeAmount: "0", + validTo: 9999999999, + creationDate: "2024-01-01T00:00:00.000Z", + signingScheme: "eip1271", + signature: "0x", + executedSellAmount: "0", + executedBuyAmount: "0", + ...overrides, + }; +} + +type RequestHandler = (req: IncomingMessage, res: ServerResponse) => void; + +async function startServer(handler: RequestHandler): Promise<{ url: string; close: () => Promise }> { + const server: Server = createServer(handler); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +/** Temporarily override `ORDERBOOK_API_URLS[chainId]` for the duration of a test callback. */ +async function withFakeApi( + chainId: number, + serverUrl: string, + fn: () => Promise, +): Promise { + const original = data.ORDERBOOK_API_URLS[chainId]; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (data.ORDERBOOK_API_URLS as any)[chainId] = serverUrl; + await fn(); + } finally { + if (original === undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (data.ORDERBOOK_API_URLS as any)[chainId]; + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (data.ORDERBOOK_API_URLS as any)[chainId] = original; + } + } +} + +const FAKE_OWNER = "0xaabbccddEEff0011223344556677889900aabbcc" as Hex; +const FAKE_CHAIN_ID = 1; +const UNKNOWN_CHAIN_ID = 99999; + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("fetchOwnerOrderStatuses", () => { + it("returns an empty map for an unknown chainId (no API URL configured)", async () => { + const result = await fetchOwnerOrderStatuses(UNKNOWN_CHAIN_ID, FAKE_OWNER); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + + it("happy path — server returns orders, Map is built with uid/status/executedAmounts", async () => { + const orders = [ + makeOrderStub({ uid: "0xuid1", status: "fulfilled", executedSellAmount: "500", executedBuyAmount: "1000" }), + makeOrderStub({ uid: "0xuid2", status: "open", executedSellAmount: "0", executedBuyAmount: "0" }), + makeOrderStub({ uid: "0xuid3", status: "expired", executedSellAmount: "250", executedBuyAmount: "500" }), + ]; + + const { url, close } = await startServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(orders)); + }); + + try { + await withFakeApi(FAKE_CHAIN_ID, url, async () => { + const result = await fetchOwnerOrderStatuses(FAKE_CHAIN_ID, FAKE_OWNER); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(3); + + expect(result.get("0xuid1")).toEqual({ + status: "fulfilled", + executedSellAmount: "500", + executedBuyAmount: "1000", + }); + expect(result.get("0xuid2")).toEqual({ + status: "open", + executedSellAmount: "0", + executedBuyAmount: "0", + }); + expect(result.get("0xuid3")).toEqual({ + status: "expired", + executedSellAmount: "250", + executedBuyAmount: "500", + }); + }); + } finally { + await close(); + } + }); + + it("handles null executedSellAmount and executedBuyAmount from the server", async () => { + const orders = [ + { + uid: "0xuid-null", + status: "cancelled", + executedSellAmount: null, + executedBuyAmount: null, + sellAmount: "1000", + buyAmount: "2000", + feeAmount: "0", + validTo: 9999999999, + creationDate: "2024-01-01T00:00:00.000Z", + signingScheme: "eip1271", + signature: "0x", + }, + ]; + + const { url, close } = await startServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(orders)); + }); + + try { + await withFakeApi(FAKE_CHAIN_ID, url, async () => { + const result = await fetchOwnerOrderStatuses(FAKE_CHAIN_ID, FAKE_OWNER); + + expect(result.size).toBe(1); + expect(result.get("0xuid-null")).toEqual({ + status: "cancelled", + executedSellAmount: null, + executedBuyAmount: null, + }); + }); + } finally { + await close(); + } + }); + + it("handles an empty orders array from the server", async () => { + const { url, close } = await startServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify([])); + }); + + try { + await withFakeApi(FAKE_CHAIN_ID, url, async () => { + const result = await fetchOwnerOrderStatuses(FAKE_CHAIN_ID, FAKE_OWNER); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + } finally { + await close(); + } + }); + + it("paginates — fetches subsequent pages when first page is full (PAGE_LIMIT=1000)", async () => { + // PAGE_LIMIT is 1000 in orderbookClient.ts. Build two pages: first exactly + // 1000 orders (triggers another fetch), second with fewer (terminates pagination). + const PAGE_LIMIT = 1000; + const page1: OrderStub[] = Array.from({ length: PAGE_LIMIT }, (_, i) => + makeOrderStub({ uid: `0xpage1-${i}`, status: "open" }), + ); + const page2: OrderStub[] = [ + makeOrderStub({ uid: "0xpage2-0", status: "fulfilled", executedSellAmount: "999", executedBuyAmount: "888" }), + ]; + + const receivedOffsets: number[] = []; + + const { url, close } = await startServer((req, res) => { + const parsedUrl = new URL(req.url ?? "/", `http://127.0.0.1`); + const offset = parseInt(parsedUrl.searchParams.get("offset") ?? "0", 10); + receivedOffsets.push(offset); + + const page = offset === 0 ? page1 : page2; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(page)); + }); + + try { + await withFakeApi(FAKE_CHAIN_ID, url, async () => { + const result = await fetchOwnerOrderStatuses(FAKE_CHAIN_ID, FAKE_OWNER); + + // Should have fetched both pages + expect(receivedOffsets).toContain(0); + expect(receivedOffsets).toContain(PAGE_LIMIT); + + // Total entries = 1000 + 1 + expect(result.size).toBe(PAGE_LIMIT + 1); + + // Spot-check the page-2 entry + expect(result.get("0xpage2-0")).toEqual({ + status: "fulfilled", + executedSellAmount: "999", + executedBuyAmount: "888", + }); + }); + } finally { + await close(); + } + }); + + it("handles a non-200 response gracefully — returns empty map without throwing", async () => { + const { url, close } = await startServer((_req, res) => { + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "Internal Server Error" })); + }); + + try { + await withFakeApi(FAKE_CHAIN_ID, url, async () => { + // fetchAccountOrders breaks out of the loop on non-ok response and + // returns whatever was accumulated so far (nothing). So result is empty. + const result = await fetchOwnerOrderStatuses(FAKE_CHAIN_ID, FAKE_OWNER); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + } finally { + await close(); + } + }); + + it("uses the correct /api/v1/account/{owner}/orders endpoint with limit and offset params", async () => { + const receivedPaths: string[] = []; + + const { url, close } = await startServer((req, res) => { + receivedPaths.push(req.url ?? ""); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify([])); + }); + + try { + await withFakeApi(FAKE_CHAIN_ID, url, async () => { + await fetchOwnerOrderStatuses(FAKE_CHAIN_ID, FAKE_OWNER); + }); + + expect(receivedPaths.length).toBeGreaterThanOrEqual(1); + const firstPath = receivedPaths[0]!; + expect(firstPath).toContain(`/api/v1/account/${FAKE_OWNER}/orders`); + expect(firstPath).toContain("limit=1000"); + expect(firstPath).toContain("offset=0"); + } finally { + await close(); + } + }); +}); From e2ba179c83b478e772aea96ae460b8f5caeafedf Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:07:52 -0300 Subject: [PATCH 3/5] fix: add maxPages guard to fetchOwnerOrderStatuses, document aged-out fallback (COW-989) Co-Authored-By: Claude Sonnet 4.6 --- docs/architecture.md | 2 ++ src/application/helpers/orderbookClient.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index d11b3ed..b6f929d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -177,6 +177,8 @@ Five live-only block handlers, all in a single file. They only run during live s **C2 — CandidateConfirmer** (every block, mainnet + gnosis): First drains any `candidateDiscreteOrder` rows whose parent generator is `Cancelled` — promoting them into `discreteOrder` with `status='cancelled'` and deleting the candidate rows. Then checks remaining `candidateDiscreteOrder` rows against the orderbook API: when a candidate appears in the API, it's promoted to `discreteOrder` and deleted from candidates. Candidates past their `validTo` are also pruned. +**TWAP aged-out fallback**: When a candidate's `orderUid` is no longer served by `/orders/by_uids` (typically after the order expires from the orderbook cache), `CandidateConfirmer` falls back to fetching the owner's full order list from `/account/{owner}/orders`. This resolves TWAP parts that the orderbook stopped tracking before C2 processed them. On timeout or API failure, the candidate defaults to `expired`. + **C3 — StatusUpdater** (every block, mainnet + gnosis): Polls the orderbook API for all `open` discrete orders and updates their status from the API response. Then sweeps any remaining `open` rows whose parent generator is `Cancelled` to `status='cancelled'` (API-terminal statuses from the loop above still win for children that were traded before on-chain cancellation). Finally expires any orders past their `validTo` timestamp. **C4 — HistoricalBootstrap** (fires once at latest block, mainnet + gnosis): One-time fetch of historical orders for non-deterministic generators (PerpetualSwap, GoodAfterTime, TradeAboveThreshold, Unknown) that were active during backfill but have no discrete orders yet. Queries the CoW Protocol `/orders?owner=` endpoint per owner. diff --git a/src/application/helpers/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index 635e6a7..03ac06a 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -303,11 +303,12 @@ export async function fetchOrderStatusByUids( export async function fetchOwnerOrderStatuses( chainId: number, owner: Hex, + maxPages = 3, ): Promise> { const result = new Map(); const apiBaseUrl = ORDERBOOK_API_URLS[chainId]; if (!apiBaseUrl) return result; - const orders = await fetchAccountOrders(apiBaseUrl, owner); + const orders = await fetchAccountOrders(apiBaseUrl, owner, maxPages); for (const order of orders) { result.set(order.uid, { status: order.status, @@ -320,13 +321,15 @@ export async function fetchOwnerOrderStatuses( // ─── API calls ─────────────────────────────────────────────────────────────── -/** Fetch all orders for an owner with pagination. */ +/** Fetch orders for an owner with pagination. maxPages limits how many pages are fetched (0 = unlimited). */ async function fetchAccountOrders( apiBaseUrl: string, owner: Hex, + maxPages = 0, ): Promise { const allOrders: OrderbookOrder[] = []; let offset = 0; + let pagesFetched = 0; // eslint-disable-next-line no-constant-condition while (true) { @@ -344,7 +347,9 @@ async function fetchAccountOrders( } const page = (await response.json()) as OrderbookOrder[]; allOrders.push(...page); + pagesFetched++; if (page.length < PAGE_LIMIT) break; // last page + if (maxPages > 0 && pagesFetched >= maxPages) break; // page cap reached offset += page.length; } catch (err) { if (err instanceof TimeoutError) { From 86e5251c64f21e5322c6aa35abcfbd050515e9af Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 10:47:55 -0300 Subject: [PATCH 4/5] fix: use BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS for paginated account fallback timeout Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/blockHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 00c0303..c85f342 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -544,7 +544,7 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { try { const ownerStatuses = await withTimeout( fetchOwnerOrderStatuses(chainId, owner), - ORDERBOOK_HTTP_TIMEOUT_MS, + BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS, "c2:stale:accountFallback", ); for (const [uid, info] of ownerStatuses) { From f5256268717435ec507978544ff5b5d16dce6f63 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 9 Jun 2026 14:33:22 -0300 Subject: [PATCH 5/5] fix: log warning when account fallback fails in CandidateConfirmer stale sweep (COW-989) Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/blockHandler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index c85f342..6a929ca 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -550,8 +550,8 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { for (const [uid, info] of ownerStatuses) { if (ownerMissedUids.has(uid)) staleStatuses.set(uid, info); } - } catch { - // Fallback failed — these UIDs will default to "expired" + } catch (err) { + console.warn(`[COW:C2] block=${event.block.number} chain=${chainId} accountFallback failed owner=${owner}`, err); } } }