diff --git a/src/application/handlers/block/orderStatusTracker.ts b/src/application/handlers/block/orderStatusTracker.ts index be0e4c4..ed83ab8 100644 --- a/src/application/handlers/block/orderStatusTracker.ts +++ b/src/application/handlers/block/orderStatusTracker.ts @@ -1,8 +1,11 @@ import { ponder } from "ponder:registry"; import { conditionalOrderGenerator, discreteOrder } from "ponder:schema"; -import { and, asc, eq, inArray, lte, sql } from "ponder"; -import { type SupportedChainId } from "../../../data"; -import { DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK } from "../../../constants"; +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, + DEFAULT_REORG_SAFETY_WINDOW_SECONDS, +} from "../../../constants"; import { fetchOrderStatusByUids } from "../../helpers/orderbookClient"; import { bumpGeneratorsUpdatedAt } from "../../helpers/updatedAtBlock"; import { log } from "../../helpers/logger"; @@ -115,11 +118,8 @@ ponder.on("OrderStatusTracker:block", async ({ event, context }) => { } } - // Parent-cancelled cascade: any open discrete_order whose parent generator - // is Cancelled and whose API state is non-terminal (not fulfilled / unfilled - // / expired / cancelled) should be cancelled from on-chain truth. The API - // loop above already applied API-terminal statuses, so what remains as - // status='open' here is exactly the "API silent" set. + // Generators cancelled on-chain — used by the soft-terminal re-poll below + // (exclusion) and the parent-cancelled cascade after it. const cancelledGeneratorIds = ( await context.db.sql .select({ id: conditionalOrderGenerator.eventId }) @@ -132,6 +132,139 @@ ponder.on("OrderStatusTracker:block", async ({ event, context }) => { ) ).map((g) => g.id); + // ── Soft-terminal re-poll (reorg self-healing — COW-1183) ────────────────── + // A terminal status written before a fork block survives Ponder's rollback, + // so a reorged-out settlement can leave discreteOrder (and the cow_cache row + // behind it) wrong. Terminal rows are therefore re-polled until the trust + // rule (orderbook/trust.ts) hardens them: fetchOrderStatusByUids serves + // hardened rows straight from cache, so only genuinely soft rows cost HTTP. + // Open orders keep priority under the per-block cap. Cascade-cancelled rows + // are excluded — their truth is the parent's on-chain Cancelled event + // (reorg-safe in Ponder's journal) and the API is silent about them, so + // polling would ping-pong them back to open. + const softBudget = maxOrdersPerBlock - openOrders.length; + if (softBudget > 0) { + const nowSeconds = Math.floor(Date.now() / 1000); + const window = + REORG_SAFETY_WINDOW_SECONDS[chainId] ?? DEFAULT_REORG_SAFETY_WINDOW_SECONDS; + + // validTo older than the window can't change anymore (fills are impossible + // after validTo) — the candidate set is only recently-terminal rows, plus + // future/null-validTo ones until their cache entry hardens. + const softCandidates = await context.db.sql + .select({ + orderUid: discreteOrder.orderUid, + conditionalOrderGeneratorId: discreteOrder.conditionalOrderGeneratorId, + status: discreteOrder.status, + validTo: discreteOrder.validTo, + }) + .from(discreteOrder) + .where( + and( + eq(discreteOrder.chainId, chainId), + inArray(discreteOrder.status, ["fulfilled", "cancelled", "expired"]), + or( + isNull(discreteOrder.validTo), + gte(discreteOrder.validTo, nowSeconds - window), + ), + ...(cancelledGeneratorIds.length > 0 + ? [notInArray(discreteOrder.conditionalOrderGeneratorId, cancelledGeneratorIds)] + : []), + ), + ) + .limit(softBudget) as { + orderUid: string; + conditionalOrderGeneratorId: string; + status: string; + validTo: number | null; + }[]; + + if (softCandidates.length > 0) { + const softStatuses = await fetchOrderStatusByUids( + context, + chainId, + softCandidates.map((o) => o.orderUid), + ); + + type SoftStatusInfo = NonNullable>; + const revertedUids: string[] = []; + const flipped: { orderUid: string; info: SoftStatusInfo }[] = []; + const touchedGeneratorIds: string[] = []; + + for (const order of softCandidates) { + const info = softStatuses.get(order.orderUid); + if (!info || info.status === order.status) continue; + if (info.status === "open") { + // Reorg revert — back to open so the normal poll loop re-resolves it. + // Skip when validTo already passed: the expiry sweep owns that row. + if (order.validTo != null && order.validTo <= Number(currentTimestamp)) continue; + revertedUids.push(order.orderUid); + touchedGeneratorIds.push(order.conditionalOrderGeneratorId); + } else if (VALID_DISCRETE_STATUSES.has(info.status)) { + flipped.push({ orderUid: order.orderUid, info }); + touchedGeneratorIds.push(order.conditionalOrderGeneratorId); + } + } + + if (revertedUids.length > 0) { + // Executed amounts came from the reorged-out settlement — clear them; + // a later fill re-populates via the open-order loop. + await context.db.sql + .update(discreteOrder) + .set({ + status: "open", + executedSellAmount: null, + executedBuyAmount: null, + executedFee: null, + updatedAtBlock: event.block.number, + }) + .where( + and( + eq(discreteOrder.chainId, chainId), + inArray(discreteOrder.orderUid, revertedUids), + ), + ); + } + + // Per-row updates: flips only happen while a reorg is healing, so this + // path is cold. Null amounts (cache-served fallbacks) keep existing + // values, mirroring the coalesce semantics of the open-order upsert. + for (const { orderUid, info } of flipped) { + await context.db.sql + .update(discreteOrder) + .set({ + status: info.status as "fulfilled" | "unfilled" | "expired" | "cancelled", + ...(info.executedSellAmount != null && { executedSellAmount: info.executedSellAmount }), + ...(info.executedBuyAmount != null && { executedBuyAmount: info.executedBuyAmount }), + ...(info.executedFee != null && { executedFee: info.executedFee }), + updatedAtBlock: event.block.number, + }) + .where( + and( + eq(discreteOrder.chainId, chainId), + eq(discreteOrder.orderUid, orderUid), + ), + ); + } + + if (touchedGeneratorIds.length > 0) { + await bumpGeneratorsUpdatedAt(context, chainId, touchedGeneratorIds, event.block.number); + await refreshTwapExecutedTotals(context, chainId, touchedGeneratorIds); + log("info", "OrderStatusTracker:REORG_HEAL", { + block: String(event.block.number), + chainId, + reverted: revertedUids.length, + flipped: flipped.length, + }); + } + } + } + + // Parent-cancelled cascade: any open discrete_order whose parent generator + // is Cancelled and whose API state is non-terminal (not fulfilled / unfilled + // / expired / cancelled) should be cancelled from on-chain truth. The API + // loop above already applied API-terminal statuses, so what remains as + // status='open' here is exactly the "API silent" set. if (cancelledGeneratorIds.length > 0) { const cascaded = await context.db.sql .update(discreteOrder) diff --git a/src/application/handlers/setup.ts b/src/application/handlers/setup.ts index 5b2f7b8..1ee036d 100644 --- a/src/application/handlers/setup.ts +++ b/src/application/handlers/setup.ts @@ -12,7 +12,10 @@ import { log } from "../helpers/logger"; * with fully qualified names. * * Cache semantics (enforced by consumers, not here): - * - Terminal states (fulfilled/expired/cancelled): cached indefinitely (cannot change) + * - Terminal states (fulfilled/expired/cancelled): cached, but only trusted + * permanently once provably beyond the chain's reorg window — see + * src/application/helpers/orderbook/trust.ts (COW-1183). Until then the + * row is "soft" and keeps being re-fetched, so reorged statuses heal. * - Open orders: not cached — always re-fetched */ ponder.on("ComposableCow:setup", async ({ context }) => { @@ -49,6 +52,16 @@ ponder.on("ComposableCow:setup", async ({ context }) => { await context.db.sql.execute(sql`ALTER TABLE cow_cache.order_uid_cache ADD COLUMN IF NOT EXISTS buy_amount TEXT`); await context.db.sql.execute(sql`ALTER TABLE cow_cache.order_uid_cache ADD COLUMN IF NOT EXISTS executed_fee TEXT`); + // Reorg-safety / healing columns (COW-1183): + // valid_to — upper bound on execution time; proves finality once + // older than the chain's reorg window + // terminal_since — wall-clock first observation of the terminal status; + // anchors the cooling-off rule for future-validTo orders + // cache_version — rows below CACHE_VERSION are re-fetched lazily (healing) + await context.db.sql.execute(sql`ALTER TABLE cow_cache.order_uid_cache ADD COLUMN IF NOT EXISTS valid_to INTEGER`); + await context.db.sql.execute(sql`ALTER TABLE cow_cache.order_uid_cache ADD COLUMN IF NOT EXISTS terminal_since BIGINT`); + await context.db.sql.execute(sql`ALTER TABLE cow_cache.order_uid_cache ADD COLUMN IF NOT EXISTS cache_version INTEGER`); + // The flash-loan enrichment now lives in order_uid_cache — drop the short-lived // dedicated table if a prior build created it. await context.db.sql.execute(sql`DROP TABLE IF EXISTS cow_cache.flash_loan_order_cache`); @@ -80,6 +93,35 @@ ponder.on("ComposableCow:setup", async ({ context }) => { ) `); await context.db.sql.execute(sql`ALTER TABLE cow_cache.composable_order ADD COLUMN IF NOT EXISTS executed_fee TEXT`); + await context.db.sql.execute(sql`ALTER TABLE cow_cache.composable_order ADD COLUMN IF NOT EXISTS terminal_since BIGINT`); + await context.db.sql.execute(sql`ALTER TABLE cow_cache.composable_order ADD COLUMN IF NOT EXISTS cache_version INTEGER`); + + // One-off idempotent backfill for rows written before the reorg-safety + // columns existed. terminal_since ≈ fetched_at is slightly generous (the + // status may be older), which only errs toward extra re-polling; truly old + // rows are covered by the valid_to fast path anyway. Pre-executed_fee + // fulfilled rows get version 0 so the lazy-healing path re-fetches them + // (replaces the old executedFee-is-null special case). + await context.db.sql.execute(sql` + UPDATE cow_cache.order_uid_cache + SET terminal_since = fetched_at + WHERE terminal_since IS NULL + `); + await context.db.sql.execute(sql` + UPDATE cow_cache.order_uid_cache + SET cache_version = CASE WHEN status = 'fulfilled' AND executed_fee IS NULL THEN 0 ELSE 1 END + WHERE cache_version IS NULL + `); + await context.db.sql.execute(sql` + UPDATE cow_cache.composable_order + SET terminal_since = fetched_at + WHERE terminal_since IS NULL AND status IN ('fulfilled', 'expired', 'cancelled') + `); + await context.db.sql.execute(sql` + UPDATE cow_cache.composable_order + SET cache_version = CASE WHEN status = 'fulfilled' AND executed_fee IS NULL THEN 0 ELSE 1 END + WHERE cache_version IS NULL + `); await context.db.sql.execute(sql` CREATE INDEX IF NOT EXISTS composable_order_owner_idx ON cow_cache.composable_order (chain_id, owner) diff --git a/src/application/helpers/orderbook/cache.ts b/src/application/helpers/orderbook/cache.ts index 1f0478c..909d64c 100644 --- a/src/application/helpers/orderbook/cache.ts +++ b/src/application/helpers/orderbook/cache.ts @@ -2,9 +2,12 @@ import { and, eq, inArray, sql } from "ponder"; import type { Context } from "ponder:registry"; import { pgSchema, integer, text, bigint, boolean } from "drizzle-orm/pg-core"; import { type Hex } from "viem"; -import { UPSERT_CHUNK_SIZE } from "../../../constants"; +import { CACHE_VERSION, DEFAULT_REORG_SAFETY_WINDOW_SECONDS, UPSERT_CHUNK_SIZE } from "../../../constants"; +import { REORG_SAFETY_WINDOW_SECONDS, type SupportedChainId } from "../../../data"; import { log } from "../logger"; +import { classifyCachedRow } from "./trust"; import { + TERMINAL_STATUSES, type CachedOrderData, type ComposableCacheRow, type ComposableOrder, @@ -54,6 +57,8 @@ const composableOrderCache = cowCacheSchema.table("composable_order", { executedBuyAmount: text("executed_buy_amount"), executedFee: text("executed_fee"), fetchedAt: bigint("fetched_at", { mode: "bigint" }).notNull(), + terminalSince: bigint("terminal_since", { mode: "number" }), + cacheVersion: integer("cache_version"), }); // Per-owner drain state for OwnerBackfillLive (see setup.ts for the DDL and the @@ -79,9 +84,14 @@ const orderUidCache = cowCacheSchema.table("order_uid_cache", { receiver: text("receiver"), sellAmount: text("sell_amount"), buyAmount: text("buy_amount"), + validTo: integer("valid_to"), + terminalSince: bigint("terminal_since", { mode: "number" }), + cacheVersion: integer("cache_version"), }); -/** Read cached flash-loan enrichment for a list of UIDs. */ +/** Read cached flash-loan enrichment for a list of UIDs. Only rows the trust + * rule considers final are served — soft rows (recently settled, or written + * by an older cache version) fall through to a fresh fetch that re-caches. */ export async function getCachedFlashLoanEnrichment( context: Context, chainId: number, @@ -89,6 +99,9 @@ export async function getCachedFlashLoanEnrichment( ): Promise> { const result = new Map(); if (uids.length === 0) return result; + const window = + REORG_SAFETY_WINDOW_SECONDS[chainId as SupportedChainId] ?? DEFAULT_REORG_SAFETY_WINDOW_SECONDS; + const now = Math.floor(Date.now() / 1000); try { const batchSize = 500; @@ -97,12 +110,17 @@ export async function getCachedFlashLoanEnrichment( const rows = await context.db.sql .select({ orderUid: orderUidCache.orderUid, + status: orderUidCache.status, receiver: orderUidCache.receiver, kind: orderUidCache.kind, sellAmount: orderUidCache.sellAmount, buyAmount: orderUidCache.buyAmount, executedSellAmount: orderUidCache.executedSellAmount, executedBuyAmount: orderUidCache.executedBuyAmount, + validTo: orderUidCache.validTo, + terminalSince: orderUidCache.terminalSince, + fetchedAt: orderUidCache.fetchedAt, + cacheVersion: orderUidCache.cacheVersion, }) .from(orderUidCache) .where( @@ -115,6 +133,7 @@ export async function getCachedFlashLoanEnrichment( // Skip discrete rows that lack enrichment (kind/amounts null). In practice // the UID sets are disjoint, so this only guards against accidental overlap. if (row.kind == null || row.sellAmount == null || row.buyAmount == null) continue; + if (classifyCachedRow(row, now, window) !== "trusted") continue; result.set(row.orderUid, { receiver: row.receiver, kind: row.kind as "sell" | "buy", @@ -140,15 +159,18 @@ export async function getCachedFlashLoanEnrichment( export async function cacheFlashLoanEnrichment( context: Context, chainId: number, - entries: { uid: string; enrichment: FlashLoanEnrichment }[], + entries: { uid: string; enrichment: FlashLoanEnrichment; validTo: number | null }[], ): Promise { if (entries.length === 0) return; const now = Math.floor(Date.now() / 1000); try { + // Upsert (not DoNothing): soft rows are re-fetched until the trust rule + // hardens them, and each re-fetch must advance fetched_at (the cooling-off + // anchor) and refresh amounts a reorg may have changed. await context.db.sql .insert(orderUidCache) .values( - entries.map(({ uid, enrichment }) => ({ + entries.map(({ uid, enrichment, validTo }) => ({ chainId, orderUid: uid, status: "fulfilled", @@ -159,9 +181,22 @@ export async function cacheFlashLoanEnrichment( executedSellAmount: enrichment.executedSellAmount, executedBuyAmount: enrichment.executedBuyAmount, fetchedAt: now, + validTo, + terminalSince: now, + cacheVersion: CACHE_VERSION, })), ) - .onConflictDoNothing(); + .onConflictDoUpdate({ + target: [orderUidCache.chainId, orderUidCache.orderUid], + set: { + executedSellAmount: sql`excluded.executed_sell_amount`, + executedBuyAmount: sql`excluded.executed_buy_amount`, + fetchedAt: now, + validTo: sql`excluded.valid_to`, + terminalSince: sql`case when ${orderUidCache.status} = excluded.status then ${orderUidCache.terminalSince} else excluded.terminal_since end`, + cacheVersion: sql`excluded.cache_version`, + }, + }); } catch (err) { log("warn", "ob:flashLoanCacheWriteFailed", { chainId, entries: entries.length, err: String(err) }); } @@ -188,6 +223,10 @@ export async function getCachedUidStatuses( executedSellAmount: orderUidCache.executedSellAmount, executedBuyAmount: orderUidCache.executedBuyAmount, executedFee: orderUidCache.executedFee, + validTo: orderUidCache.validTo, + terminalSince: orderUidCache.terminalSince, + fetchedAt: orderUidCache.fetchedAt, + cacheVersion: orderUidCache.cacheVersion, }) .from(orderUidCache) .where( @@ -202,6 +241,10 @@ export async function getCachedUidStatuses( executedSellAmount: row.executedSellAmount, executedBuyAmount: row.executedBuyAmount, executedFee: row.executedFee, + validTo: row.validTo, + terminalSince: row.terminalSince, + fetchedAt: row.fetchedAt, + cacheVersion: row.cacheVersion, }); } } @@ -212,7 +255,9 @@ export async function getCachedUidStatuses( return result; } -/** Cache terminal statuses and executed amounts for composable orders. */ +/** Cache terminal statuses and executed amounts for composable orders. + * terminal_since survives same-status re-fetches (it anchors the cooling-off + * rule in trust.ts) and resets when the status actually changed. */ export async function cacheUidStatuses( context: Context, chainId: number, @@ -232,6 +277,9 @@ export async function cacheUidStatuses( executedSellAmount: order.executedSellAmount?.toString() ?? null, executedBuyAmount: order.executedBuyAmount?.toString() ?? null, executedFee: order.executedFee?.toString() ?? null, + validTo: order.validTo ?? null, + terminalSince: now, + cacheVersion: CACHE_VERSION, }))) .onConflictDoUpdate({ target: [orderUidCache.chainId, orderUidCache.orderUid], @@ -241,6 +289,9 @@ export async function cacheUidStatuses( executedSellAmount: sql`excluded.executed_sell_amount`, executedBuyAmount: sql`excluded.executed_buy_amount`, executedFee: sql`excluded.executed_fee`, + validTo: sql`excluded.valid_to`, + terminalSince: sql`case when ${orderUidCache.status} = excluded.status then ${orderUidCache.terminalSince} else excluded.terminal_since end`, + cacheVersion: sql`excluded.cache_version`, }, }); } catch { @@ -248,6 +299,30 @@ export async function cacheUidStatuses( } } +/** Drop cache rows whose terminal status a fresh fetch just contradicted + * (reorg revert: the API says the order is open again). The next fetch + * re-caches whatever the API settles on. */ +export async function deleteUidCacheEntries( + context: Context, + chainId: number, + uids: string[], +): Promise { + if (uids.length === 0) return; + try { + await context.db.sql + .delete(orderUidCache) + .where( + and( + eq(orderUidCache.chainId, chainId), + inArray(orderUidCache.orderUid, uids), + ), + ); + log("info", "ob:cacheRevert", { chainId, uids: uids.length }); + } catch (err) { + log("warn", "ob:cacheRevertDeleteFailed", { chainId, uids: uids.length, err: String(err) }); + } +} + // ─── Durable composable-order cache helpers ─────────────────────────────────── // cow_cache.composable_order (created in setup.ts) holds full composable-order rows // keyed by (chain_id, order_uid), so the backfill drains only the delta newer than @@ -376,7 +451,7 @@ export async function readOwnerComposableCache( owner: Hex, ): Promise { try { - return (await context.db.sql + const rows = await context.db.sql .select({ orderUid: composableOrderCache.orderUid, generatorHash: composableOrderCache.generatorHash, @@ -390,6 +465,9 @@ export async function readOwnerComposableCache( executedSellAmount: composableOrderCache.executedSellAmount, executedBuyAmount: composableOrderCache.executedBuyAmount, executedFee: composableOrderCache.executedFee, + terminalSince: composableOrderCache.terminalSince, + fetchedAt: composableOrderCache.fetchedAt, + cacheVersion: composableOrderCache.cacheVersion, }) .from(composableOrderCache) .where( @@ -397,7 +475,12 @@ export async function readOwnerComposableCache( eq(composableOrderCache.chainId, chainId), eq(composableOrderCache.owner, owner.toLowerCase()), ), - )) as ComposableCacheRow[]; + ); + // fetched_at is a BIGINT column — narrow to number for the trust check. + return rows.map((row) => ({ + ...row, + fetchedAt: row.fetchedAt == null ? null : Number(row.fetchedAt), + })) as ComposableCacheRow[]; } catch { return []; } @@ -426,6 +509,7 @@ async function upsertComposableCacheChunk( now: bigint, ): Promise { if (rows.length === 0) return; + const nowSeconds = Number(now); try { await context.db.sql .insert(composableOrderCache) @@ -445,6 +529,10 @@ async function upsertComposableCacheChunk( executedBuyAmount: r.executedBuyAmount, executedFee: r.executedFee, fetchedAt: now, + // Non-terminal rows carry no cooling-off anchor; a later transition + // to terminal stamps it via the conflict CASE below. + terminalSince: TERMINAL_STATUSES.has(r.status) ? nowSeconds : null, + cacheVersion: CACHE_VERSION, }))) .onConflictDoUpdate({ target: [composableOrderCache.chainId, composableOrderCache.orderUid], @@ -455,6 +543,8 @@ async function upsertComposableCacheChunk( executedBuyAmount: sql`excluded.executed_buy_amount`, executedFee: sql`excluded.executed_fee`, fetchedAt: now, + terminalSince: sql`case when ${composableOrderCache.status} = excluded.status then ${composableOrderCache.terminalSince} else excluded.terminal_since end`, + cacheVersion: sql`excluded.cache_version`, }, }); } catch (err) { diff --git a/src/application/helpers/orderbook/client.ts b/src/application/helpers/orderbook/client.ts index 0cd61ec..665307e 100644 --- a/src/application/helpers/orderbook/client.ts +++ b/src/application/helpers/orderbook/client.ts @@ -3,15 +3,14 @@ * * Cache strategy (per-UID): * - Uses cow_cache.order_uid_cache to store per-UID terminal statuses - * - Terminal orders (fulfilled/expired/cancelled) are cached and never re-fetched + * - Terminal statuses are cached but only trusted permanently once provably + * beyond the chain's reorg window — see ./trust.ts (COW-1183). Soft rows + * keep re-fetching; a fetch that contradicts a cached terminal status + * (reorg revert) deletes the row. * - Open/non-cached orders are refreshed via POST /api/v1/orders/by_uids * - Cache is invalidated per-owner when ConditionalOrderCreated fires * - * KNOWN LIMITATION — Off-chain cancellation gap: - * Orders cancelled via the CoW Orderbook API's DELETE endpoint (off-chain - * soft cancel) are NOT detected after they've been cached as terminal. - * This is rare for EIP-1271 composable orders, which follow the on-chain - * cancellation path via ComposableCoW.remove(). + * See orderbookClient.ts (the barrel) for the known off-chain cancellation gap. */ import { and, eq, inArray, sql } from "ponder"; @@ -20,8 +19,9 @@ import { } from "ponder:schema"; import type { Context } from "ponder:registry"; import { type Hex } from "viem"; -import { ORDERBOOK_API_URLS } from "../../../data"; +import { ORDERBOOK_API_URLS, REORG_SAFETY_WINDOW_SECONDS, type SupportedChainId } from "../../../data"; import { + DEFAULT_REORG_SAFETY_WINDOW_SECONDS, ORDERBOOK_HTTP_TIMEOUT_MS, SIGNING_SCHEME_EIP1271, UPSERT_CHUNK_SIZE, @@ -35,6 +35,7 @@ import { advanceOwnerOffset, cacheFlashLoanEnrichment, cacheUidStatuses, + deleteUidCacheEntries, getCachedFlashLoanEnrichment, getCachedUidStatuses, markOwnerFullyDrained, @@ -50,6 +51,7 @@ import { reconcileOpenCachedRows, remapToCurrentGenerators, } from "./processing"; +import { classifyCachedRow } from "./trust"; import { PAGE_LIMIT, TERMINAL_STATUSES, @@ -313,6 +315,12 @@ export async function upsertDiscreteOrders( * Returns a Map of uid -> OrderStatusInfo. Executed amounts are null for * cached results (the amounts are already stored in discreteOrder from * the original fresh fetch). + * + * Cache reads go through the trust rule (trust.ts): only rows provably beyond + * the chain's reorg window are served as final. Soft rows (recently terminal, + * or written by an older cache version) are re-fetched, with the cached data + * kept as a fallback in case the UID has aged out of /by_uids. A fetch that + * contradicts a cached terminal status (reorg revert) deletes the cache row. */ export async function fetchOrderStatusByUids( context: Context, @@ -325,29 +333,30 @@ export async function fetchOrderStatusByUids( const apiBaseUrl = ORDERBOOK_API_URLS[chainId]; if (!apiBaseUrl) return result; - // Check cache first. Fulfilled entries with a null executedFee predate the - // executed_fee cache column and would otherwise stay stale forever (terminal - // entries are never re-fetched) — treat them as misses, but keep the cached - // data as a fallback in case the UID has aged out of /by_uids. Expired and - // cancelled entries executed nothing, so a null fee there is left alone. + const window = + REORG_SAFETY_WINDOW_SECONDS[chainId as SupportedChainId] ?? + DEFAULT_REORG_SAFETY_WINDOW_SECONDS; + const nowSeconds = Math.floor(Date.now() / 1000); + const cached = await getCachedUidStatuses(context, chainId, uids); const toFetch: string[] = []; const staleFallbacks = new Map(); for (const uid of uids) { const cachedData = cached.get(uid); - if (cachedData && TERMINAL_STATUSES.has(cachedData.status)) { + const trust = cachedData ? classifyCachedRow(cachedData, nowSeconds, window) : null; + if (cachedData && trust !== null && trust !== "not-terminal") { const info: OrderStatusInfo = { status: cachedData.status, executedSellAmount: toBigIntOrNull(cachedData.executedSellAmount), executedBuyAmount: toBigIntOrNull(cachedData.executedBuyAmount), executedFee: toBigIntOrNull(cachedData.executedFee), }; - if (cachedData.status === "fulfilled" && cachedData.executedFee == null) { + if (trust === "trusted") { + result.set(uid, info); + } else { staleFallbacks.set(uid, info); toFetch.push(uid); - } else { - result.set(uid, info); } } else { toFetch.push(uid); @@ -377,6 +386,7 @@ export async function fetchOrderStatusByUids( } const newTerminal: ComposableOrder[] = []; + const reverted: string[] = []; for (const order of fetched) { result.set(order.uid, { @@ -385,6 +395,12 @@ export async function fetchOrderStatusByUids( executedBuyAmount: toBigIntOrNull(order.executedBuyAmount), executedFee: toBigIntOrNull(order.executedFee), }); + // A cached terminal status the API now contradicts was reorged out — + // drop the row so the stale fallback can't be served again. + if (!TERMINAL_STATUSES.has(order.status) && staleFallbacks.has(order.uid)) { + reverted.push(order.uid); + staleFallbacks.delete(order.uid); + } if (TERMINAL_STATUSES.has(order.status)) { newTerminal.push({ uid: order.uid, @@ -407,6 +423,9 @@ export async function fetchOrderStatusByUids( if (newTerminal.length > 0) { await cacheUidStatuses(context, chainId, newTerminal); } + if (reverted.length > 0) { + await deleteUidCacheEntries(context, chainId, reverted); + } // Stale UIDs the API no longer returns (aged out of /by_uids): answer with // the cached data rather than omitting them, so callers don't mistake a @@ -492,7 +511,7 @@ export async function fetchFlashLoanEnrichmentByUids( throw err; } - const newlyFetched: { uid: string; enrichment: FlashLoanEnrichment }[] = []; + const newlyFetched: { uid: string; enrichment: FlashLoanEnrichment; validTo: number | null }[] = []; for (const order of fetched) { const enrichment: FlashLoanEnrichment = { receiver: order.receiver ? order.receiver.toLowerCase() : null, @@ -503,7 +522,7 @@ export async function fetchFlashLoanEnrichmentByUids( executedBuyAmount: order.executedBuyAmount, }; result.set(order.uid, enrichment); - newlyFetched.push({ uid: order.uid, enrichment }); + newlyFetched.push({ uid: order.uid, enrichment, validTo: order.validTo ?? null }); } if (newlyFetched.length > 0) { diff --git a/src/application/helpers/orderbook/processing.ts b/src/application/helpers/orderbook/processing.ts index 91277ed..fee8357 100644 --- a/src/application/helpers/orderbook/processing.ts +++ b/src/application/helpers/orderbook/processing.ts @@ -5,13 +5,17 @@ import { } from "ponder:schema"; import { encodeAbiParameters, keccak256, type Hex } from "viem"; import { type OrderType } from "../../../utils/order-types"; -import { COMPOSABLE_COW_HANDLER_ADDRESSES } from "../../../data"; -import { SIGNING_SCHEME_EIP1271 } from "../../../constants"; +import { + COMPOSABLE_COW_HANDLER_ADDRESSES, + REORG_SAFETY_WINDOW_SECONDS, + type SupportedChainId, +} from "../../../data"; +import { DEFAULT_REORG_SAFETY_WINDOW_SECONDS, SIGNING_SCHEME_EIP1271 } from "../../../constants"; import { decodeEip1271Signature } from "../../decoders/erc1271Signature"; import { fetchOrdersByUids } from "./http"; import { upsertComposableCache } from "./cache"; +import { classifyCachedRow } from "./trust"; import { - TERMINAL_STATUSES, toBigIntOrNull, type ComposableCacheRow, type ComposableOrder, @@ -112,12 +116,11 @@ export async function filterAndProcess( return results; } -/** Re-check non-terminal cached rows via by_uids; update status/validTo/executed and - * re-persist any that became terminal. Mutates and returns `rows`. - * Fulfilled rows with a null executedFee are also re-checked: they were cached - * before the executed_fee column existed and would otherwise stay stale forever - * (terminal rows are never re-fetched). Expired/cancelled rows are left alone — - * nothing was executed, so a null fee there is harmless. */ +/** Re-check cached rows the trust rule doesn't consider final (open rows, and + * terminal rows still inside the chain's reorg window or written by an older + * cache version — see trust.ts) via by_uids; update status/validTo/executed + * and re-persist every row the fetch touched, including terminal statuses a + * reorg reverted back to open. Mutates and returns `rows`. */ export async function reconcileOpenCachedRows( context: Context, chainId: number, @@ -126,19 +129,33 @@ export async function reconcileOpenCachedRows( rows: ComposableCacheRow[], signal?: AbortSignal, ): Promise { - const openUids = rows + const window = + REORG_SAFETY_WINDOW_SECONDS[chainId as SupportedChainId] ?? + DEFAULT_REORG_SAFETY_WINDOW_SECONDS; + const nowSeconds = Math.floor(Date.now() / 1000); + + const staleUids = rows .filter((r) => - !TERMINAL_STATUSES.has(r.status) || - (r.status === "fulfilled" && r.executedFee == null), + classifyCachedRow( + { + status: r.status, + validTo: r.validTo, + terminalSince: r.terminalSince ?? null, + fetchedAt: r.fetchedAt ?? null, + cacheVersion: r.cacheVersion ?? null, + }, + nowSeconds, + window, + ) !== "trusted", ) .map((r) => r.orderUid); - if (openUids.length === 0) return rows; + if (staleUids.length === 0) return rows; - const refreshed = await fetchOrdersByUids(apiBaseUrl, openUids, signal); + const refreshed = await fetchOrdersByUids(apiBaseUrl, staleUids, signal); if (refreshed.length === 0) return rows; const byUid = new Map(refreshed.map((o) => [o.uid, o])); - const newlyTerminal: ComposableCacheRow[] = []; + const touched: ComposableCacheRow[] = []; for (const row of rows) { const fresh = byUid.get(row.orderUid); if (!fresh) continue; @@ -147,11 +164,11 @@ export async function reconcileOpenCachedRows( row.executedSellAmount = fresh.executedSellAmount; row.executedBuyAmount = fresh.executedBuyAmount; row.executedFee = fresh.executedFee; - if (TERMINAL_STATUSES.has(fresh.status)) newlyTerminal.push(row); + touched.push(row); } - if (newlyTerminal.length > 0) { - await upsertComposableCache(context, chainId, owner, newlyTerminal); + if (touched.length > 0) { + await upsertComposableCache(context, chainId, owner, touched); } return rows; } diff --git a/src/application/helpers/orderbook/trust.test.ts b/src/application/helpers/orderbook/trust.test.ts new file mode 100644 index 0000000..de5d646 --- /dev/null +++ b/src/application/helpers/orderbook/trust.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "vitest"; +import { classifyCachedRow } from "./trust"; +import { CACHE_VERSION } from "../../../constants"; + +const NOW = 1_800_000_000; // arbitrary wall-clock seconds +const W = 1200; // 20-minute reorg safety window + +describe("classifyCachedRow", () => { + it("trusts a terminal row whose validTo passed more than the window ago", () => { + // The backfill case: an order that expired/filled ages ago. No fill can + // happen after validTo, so the terminal status is final — no re-fetching. + const trust = classifyCachedRow( + { + status: "fulfilled", + validTo: NOW - 10 * W, + terminalSince: NOW, // just cached — must not matter on the fast path + fetchedAt: NOW, + cacheVersion: CACHE_VERSION, + }, + NOW, + W, + ); + expect(trust).toBe("trusted"); + }); + + it("trusts a far-future-validTo row once a fetch re-confirmed it more than W after first seen", () => { + // A long-dated order that filled recently: validTo can't prove anything, + // but the status was still "fulfilled" when fetched > W after we first saw + // it — the settlement survived a full reorg window of real time. + const trust = classifyCachedRow( + { + status: "fulfilled", + validTo: NOW + 365 * 24 * 3600, + terminalSince: NOW - 2 * W, + fetchedAt: NOW - 2 * W + (W + 1), + cacheVersion: CACHE_VERSION, + }, + NOW, + W, + ); + expect(trust).toBe("trusted"); + }); + + it("keeps a freshly-terminal row soft until the cooling-off fetch happens", () => { + // Just saw it go terminal, and the only fetch is the one that discovered + // it. A reorg could still take the settlement back — keep re-fetching. + const trust = classifyCachedRow( + { + status: "fulfilled", + validTo: NOW + 3600, + terminalSince: NOW - 60, + fetchedAt: NOW - 60, + cacheVersion: CACHE_VERSION, + }, + NOW, + W, + ); + expect(trust).toBe("soft"); + }); + + it("treats a stale-version row as soft even when it is otherwise final", () => { + // Healing (COW-1183 gap 1): rows cached before a column existed must be + // re-fetched once, no matter how old the order is. + const trust = classifyCachedRow( + { + status: "fulfilled", + validTo: NOW - 10 * W, + terminalSince: NOW - 10 * W, + fetchedAt: NOW - 5 * W, + cacheVersion: CACHE_VERSION - 1, + }, + NOW, + W, + ); + expect(trust).toBe("soft"); + }); + + it("classifies a non-terminal status as not-terminal (plain miss)", () => { + const trust = classifyCachedRow( + { status: "open", validTo: NOW - 10 * W, terminalSince: null, fetchedAt: NOW, cacheVersion: CACHE_VERSION }, + NOW, + W, + ); + expect(trust).toBe("not-terminal"); + }); + + it("keeps a row soft when terminal_since is missing and validTo can't prove finality", () => { + // Rows written by paths that never observed the transition (or migrated + // rows) have no cooling-off anchor — they stay soft until validTo ages out + // or a re-fetch stamps terminal_since and the window passes. + const trust = classifyCachedRow( + { status: "cancelled", validTo: NOW + 3600, terminalSince: null, fetchedAt: NOW, cacheVersion: CACHE_VERSION }, + NOW, + W, + ); + expect(trust).toBe("soft"); + }); + + it("is strict at the window boundary — exactly W is not enough", () => { + expect( + classifyCachedRow( + { status: "fulfilled", validTo: NOW - W, terminalSince: null, fetchedAt: NOW, cacheVersion: CACHE_VERSION }, + NOW, + W, + ), + ).toBe("soft"); + expect( + classifyCachedRow( + { status: "fulfilled", validTo: NOW + 3600, terminalSince: NOW - 2 * W, fetchedAt: NOW - W, cacheVersion: CACHE_VERSION }, + NOW, + W, + ), + ).toBe("soft"); + }); +}); diff --git a/src/application/helpers/orderbook/trust.ts b/src/application/helpers/orderbook/trust.ts new file mode 100644 index 0000000..bbfa8cc --- /dev/null +++ b/src/application/helpers/orderbook/trust.ts @@ -0,0 +1,70 @@ +/** + * Trust classification for cached terminal order statuses (COW-1183). + * + * The cow_cache tables live outside Ponder's reorg journal, so a terminal + * status cached right after a settlement can silently outlive a reorg. Reorg + * detection can't close this: block handlers start at "latest" and spend + * minutes to hours catching up after every deploy, during which the orderbook + * API answers from the real tip while our cursor is behind — reorgs up there + * resolve before we ever process those heights. + * + * Instead, a terminal row is only trusted permanently once it is provably + * beyond the chain's reorg window W (per-chain, see reorgSafetyWindowSeconds): + * + * - Fast path: validTo passed more than W ago. GPv2 rejects fills after + * validTo, so validTo is an upper bound on execution time — old orders + * (the whole owner-backfill flood) are final with zero re-fetching. + * - Cooling-off: otherwise the row is "soft" until a fetch made more than W + * after terminal_since re-confirmed the status. Wall clock is immune to + * indexer lag: the API can only report a fill after it happened in real + * time, so "still terminal W after first seen" proves the settlement + * survived W of real time. + * + * Soft rows are served but keep being re-fetched by the callers' existing + * batched miss paths; a reorged-out status heals on the next poll. + */ + +import { CACHE_VERSION } from "../../../constants"; +import { TERMINAL_STATUSES } from "./types"; + +export type CacheTrust = "trusted" | "soft" | "not-terminal"; + +export interface TrustInputs { + status: string; + validTo: number | null; + /** Wall-clock seconds when this terminal status was first observed. */ + terminalSince: number | null; + /** Wall-clock seconds of the fetch that last wrote this row. */ + fetchedAt: number | null; + cacheVersion: number | null; +} + +/** + * Classify a cached row: "trusted" rows are final and served as-is forever; + * "soft" rows are served but must be re-fetched (treated as misses with the + * cached data kept as fallback); "not-terminal" rows are plain misses. + */ +export function classifyCachedRow( + row: TrustInputs, + nowSeconds: number, + windowSeconds: number, +): CacheTrust { + if (!TERMINAL_STATUSES.has(row.status)) return "not-terminal"; + + // Stale-version rows must heal (re-fetch once) before any finality shortcut. + if (row.cacheVersion !== CACHE_VERSION) return "soft"; + + if (row.validTo != null && row.validTo < nowSeconds - windowSeconds) { + return "trusted"; + } + + if ( + row.terminalSince != null && + row.fetchedAt != null && + row.fetchedAt - row.terminalSince > windowSeconds + ) { + return "trusted"; + } + + return "soft"; +} diff --git a/src/application/helpers/orderbook/types.ts b/src/application/helpers/orderbook/types.ts index f862a79..43f1e15 100644 --- a/src/application/helpers/orderbook/types.ts +++ b/src/application/helpers/orderbook/types.ts @@ -73,6 +73,10 @@ export interface ComposableCacheRow { executedSellAmount: string | null; executedBuyAmount: string | null; executedFee: string | null; + /** Read-side trust fields (see trust.ts) — absent on freshly-decoded rows. */ + terminalSince?: number | null; + fetchedAt?: number | null; + cacheVersion?: number | null; } /** Cached order data returned by getCachedUidStatuses. */ @@ -81,6 +85,10 @@ export interface CachedOrderData { executedSellAmount: string | null; executedBuyAmount: string | null; executedFee: string | null; + validTo: number | null; + terminalSince: number | null; + fetchedAt: number | null; + cacheVersion: number | null; } export const TERMINAL_STATUSES = new Set(["fulfilled", "expired", "cancelled"]); diff --git a/src/application/helpers/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index 3abf1a0..6ff16d7 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -3,26 +3,26 @@ * * Cache strategy (per-UID): * - Uses cow_cache.order_uid_cache to store per-UID terminal statuses - * - Terminal orders (fulfilled/expired/cancelled) are cached and never re-fetched + * - Terminal orders (fulfilled/expired/cancelled) are cached, but only trusted + * permanently once provably beyond the chain's reorg window (COW-1183) — + * see ./orderbook/trust.ts. Until then the row is "soft": served, but + * re-fetched on every read so a reorged-out settlement heals on the next + * poll. Rows written by an older CACHE_VERSION also re-fetch lazily, which + * is how new columns (e.g. executed_fee) heal on historical rows. * - Open/non-cached orders are refreshed via POST /api/v1/orders/by_uids * - Cache is invalidated per-owner when ConditionalOrderCreated fires * * KNOWN LIMITATION — Off-chain cancellation gap: * Orders cancelled via the CoW Orderbook API's DELETE endpoint (off-chain - * soft cancel) are NOT detected after they've been cached as terminal. + * soft cancel) are NOT detected once the cached status has hardened past + * the reorg window (soft-window cancels are caught by the re-polling). * This is rare for EIP-1271 composable orders, which follow the on-chain - * cancellation path via ComposableCoW.remove(). - * - * KNOWN LIMITATION — Settlement reorg gap (tracked as COW-1183): - * Terminal statuses live in cow_cache outside Ponder's reorg journal and are - * never re-fetched. TWAP parent totals remain consistent with discreteOrder, - * but both can retain a settlement that reorged out until terminal-status - * caching gains a finality-aware policy. Never re-fetching also means cache - * rows written before a column existed (e.g. executed_fee) stay null. + * cancellation path via ComposableCoW.remove(), and cannot produce wrong + * executed amounts — only a stale fulfilled/expired vs cancelled label. * * This module is a thin barrel: the implementation lives in ./orderbook/* - * (types, http, cache, processing, client). It re-exports the public API so - * existing import paths keep working. + * (types, http, cache, processing, client, trust). It re-exports the public + * API so existing import paths keep working. */ export { diff --git a/src/chains/arbitrum.ts b/src/chains/arbitrum.ts index 04873da..bb6d06d 100644 --- a/src/chains/arbitrum.ts +++ b/src/chains/arbitrum.ts @@ -32,4 +32,5 @@ export const arbitrum: ChainConfig = { }, orderbookApiPath: "arbitrum_one", orderbookPollInterval: 20, // ~20 blocks at 1s/block (prior global cadence) + reorgSafetyWindowSeconds: 1200, // 20 min — covers L1-reorg derived resets }; diff --git a/src/chains/avalanche.ts b/src/chains/avalanche.ts index 8acc7fa..20241ae 100644 --- a/src/chains/avalanche.ts +++ b/src/chains/avalanche.ts @@ -32,4 +32,5 @@ export const avalanche: ChainConfig = { }, orderbookApiPath: "avalanche", // TODO: verify CoW Protocol orderbook URL for Avalanche orderbookPollInterval: 40, // ~20 blocks at 2s/block (prior global cadence) + reorgSafetyWindowSeconds: 300, // 5 min — sub-second finality plus margin }; diff --git a/src/chains/base.ts b/src/chains/base.ts index 0554be3..3bb089c 100644 --- a/src/chains/base.ts +++ b/src/chains/base.ts @@ -32,4 +32,5 @@ export const base: ChainConfig = { }, orderbookApiPath: "base", orderbookPollInterval: 40, // ~20 blocks at 2s/block (prior global cadence) + reorgSafetyWindowSeconds: 1200, // 20 min — covers L1-reorg derived resets }; diff --git a/src/chains/bnb.ts b/src/chains/bnb.ts index 75e0473..fce9e25 100644 --- a/src/chains/bnb.ts +++ b/src/chains/bnb.ts @@ -32,4 +32,5 @@ export const bnb: ChainConfig = { }, orderbookApiPath: "bnb", // TODO: verify CoW Protocol orderbook URL for BNB orderbookPollInterval: 60, // ~20 blocks at 3s/block (prior global cadence) + reorgSafetyWindowSeconds: 900, // 15 min — fast finality plus margin }; diff --git a/src/chains/gnosis.ts b/src/chains/gnosis.ts index ebaf708..56aad48 100644 --- a/src/chains/gnosis.ts +++ b/src/chains/gnosis.ts @@ -35,4 +35,5 @@ export const gnosis: ChainConfig = { }, orderbookApiPath: "xdai", orderbookPollInterval: 100, // ~20 blocks at 5s/block (prior global cadence) + reorgSafetyWindowSeconds: 300, // 5 min — fast finality }; diff --git a/src/chains/ink.ts b/src/chains/ink.ts index 65dd14f..8ac6535 100644 --- a/src/chains/ink.ts +++ b/src/chains/ink.ts @@ -21,4 +21,5 @@ export const ink: ChainConfig = { flashLoan: null, // TODO: set { aaveV3: { router, adapterFactory } } once flash-loan infra is confirmed on Ink orderbookApiPath: "ink", // TODO: verify CoW Protocol orderbook URL for Ink orderbookPollInterval: 20 * blockTime, + reorgSafetyWindowSeconds: 1200, // 20 min — covers L1-reorg derived resets }; diff --git a/src/chains/lens.ts b/src/chains/lens.ts index 5ee6879..5155f7e 100644 --- a/src/chains/lens.ts +++ b/src/chains/lens.ts @@ -24,4 +24,5 @@ export const lens: ChainConfig = { flashLoan: null, // TODO: set { aaveV3: { router, adapterFactory } } once flash-loan infra is confirmed on Lens orderbookApiPath: "lens", // NOTE: api.cow.fi/lens returns 404 — orderbook not live for Lens yet orderbookPollInterval: 20 * blockTime, + reorgSafetyWindowSeconds: 1200, // 20 min — covers L1-reorg derived resets }; diff --git a/src/chains/linea.ts b/src/chains/linea.ts index ee3b556..dfd5316 100644 --- a/src/chains/linea.ts +++ b/src/chains/linea.ts @@ -32,4 +32,5 @@ export const linea: ChainConfig = { }, orderbookApiPath: "linea", // TODO: verify CoW Protocol orderbook URL for Linea orderbookPollInterval: 60, // ~20 blocks at 3s/block (prior global cadence) + reorgSafetyWindowSeconds: 1200, // 20 min — covers L1-reorg derived resets }; diff --git a/src/chains/mainnet.ts b/src/chains/mainnet.ts index f7970fd..1c7e07a 100644 --- a/src/chains/mainnet.ts +++ b/src/chains/mainnet.ts @@ -32,4 +32,5 @@ export const mainnet: ChainConfig = { }, orderbookApiPath: "mainnet", orderbookPollInterval: 240, // ~20 blocks at 12s/block (prior global cadence) + reorgSafetyWindowSeconds: 1200, // 20 min — Ethereum finality ~13 min plus margin }; diff --git a/src/chains/plasma.ts b/src/chains/plasma.ts index 3e09685..96d87d4 100644 --- a/src/chains/plasma.ts +++ b/src/chains/plasma.ts @@ -32,4 +32,5 @@ export const plasma: ChainConfig = { }, orderbookApiPath: "plasma", // TODO: verify CoW Protocol orderbook URL for Plasma orderbookPollInterval: 20, // ~20 blocks at 1s/block (prior global cadence) + reorgSafetyWindowSeconds: 1200, // 20 min — covers L1-reorg derived resets }; diff --git a/src/chains/polygon.ts b/src/chains/polygon.ts index 73added..cd12428 100644 --- a/src/chains/polygon.ts +++ b/src/chains/polygon.ts @@ -32,4 +32,5 @@ export const polygon: ChainConfig = { }, orderbookApiPath: "polygon", // TODO: verify CoW Protocol orderbook URL for Polygon orderbookPollInterval: 40, // ~20 blocks at 2s/block (prior global cadence) + reorgSafetyWindowSeconds: 900, // 15 min — milestone finality plus margin }; diff --git a/src/chains/sepolia.ts b/src/chains/sepolia.ts index 6e2d60d..ac79ab0 100644 --- a/src/chains/sepolia.ts +++ b/src/chains/sepolia.ts @@ -24,4 +24,5 @@ export const sepolia: ChainConfig = { flashLoan: null, // TODO: set { aaveV3: { router, adapterFactory } } once flash-loan infra is confirmed on Sepolia orderbookApiPath: "sepolia", orderbookPollInterval: 20 * blockTime, + reorgSafetyWindowSeconds: 300, // 5 min — testnet }; diff --git a/src/chains/types.ts b/src/chains/types.ts index 28b46c9..5511c01 100644 --- a/src/chains/types.ts +++ b/src/chains/types.ts @@ -77,4 +77,15 @@ export interface ChainConfig { * Defaults to `20 * blockTime` to preserve the prior 20-block cadence. */ orderbookPollInterval: number; + + /** + * Reorg safety window for this chain, in **seconds** (wall-clock). + * A terminal orderbook status (fulfilled/expired/cancelled) is only cached + * permanently once it is provably older than this window — before that it + * is kept "soft" and re-polled, so a status reverted by a reorg heals on + * the next poll (see src/application/helpers/orderbook/trust.ts). + * Pick the chain's finality time rounded up plus margin; erring long only + * keeps orders in an already-batched poll a little longer. + */ + reorgSafetyWindowSeconds: number; } diff --git a/src/constants.ts b/src/constants.ts index 024fe9d..31e8d5a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -170,3 +170,18 @@ export const MAX_FLASH_LOAN_ENRICHMENT_ATTEMPTS = 10; * concurrency (each slice fans out to ceil(size / 50) parallel by_uids requests). */ export const FLASH_LOAN_BACKFILL_SLICE_SIZE = 500; + +/** + * Version stamp for cow_cache terminal rows. Bump when a cache row gains a + * column that older rows must heal (they are then treated as misses and + * re-fetched lazily on next read, keeping the stale data as a fallback). + * Version 1 = rows carrying executed_fee; pre-executed_fee rows migrate to 0. + */ +export const CACHE_VERSION = 1; + +/** + * Fallback reorg safety window when a chain has no configured + * reorgSafetyWindowSeconds (see REORG_SAFETY_WINDOW_SECONDS in src/data.ts). + * Conservative 20 minutes — erring long only extends soft-cache re-polling. + */ +export const DEFAULT_REORG_SAFETY_WINDOW_SECONDS = 1200; diff --git a/src/data.ts b/src/data.ts index a6f2757..9c8c132 100644 --- a/src/data.ts +++ b/src/data.ts @@ -61,6 +61,16 @@ export const ORDERBOOK_API_URLS: Record = Object.fromEntries( ALL_DEFINED_CHAINS.map((c) => [c.chainId, `https://api.cow.fi/${c.orderbookApiPath}`]), ); +/** + * Per-chain reorg safety window in seconds, keyed by chain ID. + * Derived from each chain's reorgSafetyWindowSeconds — see ChainConfig for the + * caching semantics. Partial: fall back to DEFAULT_REORG_SAFETY_WINDOW_SECONDS. + */ +export const REORG_SAFETY_WINDOW_SECONDS: Partial> = + Object.fromEntries( + ALL_DEFINED_CHAINS.map((c) => [c.chainId, c.reorgSafetyWindowSeconds]), + ); + /** * Aave V3 adapter factory addresses keyed by chain name. * Derived from ACTIVE_CHAINS — only chains with Aave V3 flash-loan infra are included. diff --git a/tests/helpers/orderbookClient.test.ts b/tests/helpers/orderbookClient.test.ts index d734f9d..06ba806 100644 --- a/tests/helpers/orderbookClient.test.ts +++ b/tests/helpers/orderbookClient.test.ts @@ -19,7 +19,7 @@ vi.mock("ponder", () => ({ })); import * as data from "../../src/data"; -import { ORDERBOOK_MAX_RETRIES, UPSERT_CHUNK_SIZE } from "../../src/constants"; +import { CACHE_VERSION, ORDERBOOK_MAX_RETRIES, UPSERT_CHUNK_SIZE } from "../../src/constants"; import { drainOwnerSlice, fetchAccountOrders, @@ -227,20 +227,41 @@ describe("fetchOrderStatusByUids", () => { }); }); -// ─── Stale-cache fetch-through (fulfilled entries missing executedFee) ──────── +// ─── Cache trust rule (COW-1183): soft rows re-fetch, trusted rows serve ───── -describe("fetchOrderStatusByUids — stale fulfilled cache entries", () => { - /** Context stub whose per-UID cache read returns `rows`; cache writes are no-ops. */ - function makeCacheContext(rows: Record[]): Context { - return { +describe("fetchOrderStatusByUids — cache trust rule", () => { + /** Context stub whose per-UID cache read returns `rows`; cache writes are no-ops. + * Deletions (reorg reverts) are recorded in `deleted`. */ + function makeCacheContext(rows: Record[]) { + const deleted: unknown[] = []; + const ctx = { db: { sql: { select: () => ({ from: () => ({ where: async () => rows }) }), insert: () => ({ values: () => ({ onConflictDoUpdate: async () => undefined }) }), + delete: () => ({ where: async () => { deleted.push(1); } }), execute: async () => [], }, }, } as unknown as Context; + return Object.assign(ctx, { __deleted: deleted }); + } + + const NOW = Math.floor(Date.now() / 1000); + /** A row the trust rule considers final: validTo long past, current version. */ + function trustedRow(overrides: Record = {}) { + return { + orderUid: UID_A, + status: "fulfilled", + executedSellAmount: "1", + executedBuyAmount: "2", + executedFee: "3", + validTo: NOW - 100 * 24 * 3600, + terminalSince: NOW - 100 * 24 * 3600, + fetchedAt: NOW - 99 * 24 * 3600, + cacheVersion: CACHE_VERSION, + ...overrides, + }; } beforeAll(() => { @@ -298,7 +319,7 @@ describe("fetchOrderStatusByUids — stale fulfilled cache entries", () => { } }); - it("serves fulfilled entries with a concrete executedFee straight from cache", async () => { + it("serves trusted entries (validTo past the reorg window, current version) straight from cache", async () => { let calls = 0; const { url, close } = await startServer((_req, res) => { calls++; @@ -306,13 +327,51 @@ describe("fetchOrderStatusByUids — stale fulfilled cache entries", () => { res.end("[]"); }); data.ORDERBOOK_API_URLS[TEST_CHAIN_ID] = url; + const ctx = makeCacheContext([trustedRow()]); + try { + const result = await fetchOrderStatusByUids(ctx, TEST_CHAIN_ID, [UID_A]); + expect(calls).toBe(0); // no network — the row is provably final + expect(result.get(UID_A)?.executedFee).toBe(3n); + } finally { + await close(); + } + }); + + it("re-fetches a soft terminal entry (still inside the reorg window) even when complete", async () => { + let calls = 0; + const { url, close } = await startServer((_req, res) => { + calls++; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify([makeWrappedOrder(UID_A, "fulfilled")])); + }); + data.ORDERBOOK_API_URLS[TEST_CHAIN_ID] = url; + // Fulfilled seconds ago with a far-future validTo: a reorg could still + // revert it, so the cache must not be trusted yet. const ctx = makeCacheContext([ - { orderUid: UID_A, status: "fulfilled", executedSellAmount: "1", executedBuyAmount: "2", executedFee: "3" }, + trustedRow({ validTo: NOW + 3600, terminalSince: NOW - 30, fetchedAt: NOW - 30 }), ]); try { const result = await fetchOrderStatusByUids(ctx, TEST_CHAIN_ID, [UID_A]); - expect(calls).toBe(0); // no network — cache is complete - expect(result.get(UID_A)?.executedFee).toBe(3n); + expect(calls).toBe(1); // soft — went to the API + expect(result.get(UID_A)?.status).toBe("fulfilled"); + } finally { + await close(); + } + }); + + it("deletes the cache row and reports open when the API contradicts a soft terminal entry (reorg revert)", async () => { + const { url, close } = await startServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify([makeWrappedOrder(UID_A, "open")])); + }); + data.ORDERBOOK_API_URLS[TEST_CHAIN_ID] = url; + const ctx = makeCacheContext([ + trustedRow({ validTo: NOW + 3600, terminalSince: NOW - 30, fetchedAt: NOW - 30 }), + ]); + try { + const result = await fetchOrderStatusByUids(ctx, TEST_CHAIN_ID, [UID_A]); + expect(result.get(UID_A)?.status).toBe("open"); // fresh truth, not the stale fallback + expect((ctx as unknown as { __deleted: unknown[] }).__deleted.length).toBe(1); } finally { await close(); } @@ -1174,14 +1233,21 @@ describe("fetchFlashLoanEnrichmentByUids", () => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify([])); }); + const now = Math.floor(Date.now() / 1000); const cachedRow = { orderUid: UID_A, + status: "fulfilled", receiver: "0xcccccccccccccccccccccccccccccccccccccccc", kind: "buy", sellAmount: "111", buyAmount: "222", executedSellAmount: "111", executedBuyAmount: "220", + // Trust-rule fields: only rows past the reorg window are served from cache. + validTo: now - 100 * 24 * 3600, + terminalSince: now - 100 * 24 * 3600, + fetchedAt: now - 99 * 24 * 3600, + cacheVersion: CACHE_VERSION, }; const ctx = { db: { sql: { select: () => ({ from: () => ({ where: async () => [cachedRow] }) }) } } } as unknown as Context; try { @@ -1206,7 +1272,7 @@ describe("fetchFlashLoanEnrichmentByUids", () => { db: { sql: { select: () => ({ from: () => ({ where: async () => [] }) }), // empty cache - insert: () => ({ values: (vals: Record[]) => ({ onConflictDoNothing: async () => { inserted.push(...vals); } }) }), + insert: () => ({ values: (vals: Record[]) => ({ onConflictDoUpdate: async () => { inserted.push(...vals); } }) }), }, }, } as unknown as Context; @@ -1217,6 +1283,10 @@ describe("fetchFlashLoanEnrichmentByUids", () => { expect(inserted[0]!.orderUid).toBe(UID_A); expect(inserted[0]!.kind).toBe("sell"); expect(inserted[0]!.receiver).toBe("0xcccccccccccccccccccccccccccccccccccccccc"); + // Trust-rule columns stamped on write so the row can harden later. + expect(inserted[0]!.validTo).toBe(9_999_999_999); + expect(inserted[0]!.cacheVersion).toBe(CACHE_VERSION); + expect(typeof inserted[0]!.terminalSince).toBe("number"); }); } finally { await close(); diff --git a/tests/reorg-safety-window.test.ts b/tests/reorg-safety-window.test.ts new file mode 100644 index 0000000..cb7fc0f --- /dev/null +++ b/tests/reorg-safety-window.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { ACTIVE_CHAINS } from "../src/chains"; +import { REORG_SAFETY_WINDOW_SECONDS } from "../src/data"; + +describe("REORG_SAFETY_WINDOW_SECONDS", () => { + it("has a window for every active chain", () => { + for (const chain of ACTIVE_CHAINS) { + expect( + REORG_SAFETY_WINDOW_SECONDS[chain.chainId], + `missing reorg safety window for ${chain.name}`, + ).toBeDefined(); + } + }); + + it("windows are at least 60 seconds — anything shorter is inside normal reorg depth", () => { + for (const chain of ACTIVE_CHAINS) { + expect( + REORG_SAFETY_WINDOW_SECONDS[chain.chainId]!, + `window too small for ${chain.name}`, + ).toBeGreaterThanOrEqual(60); + } + }); + + it("mainnet window comfortably covers ~13min finality", () => { + expect(REORG_SAFETY_WINDOW_SECONDS[1]!).toBeGreaterThanOrEqual(1200); + }); +});