diff --git a/docs/architecture.md b/docs/architecture.md index ff5abef..31e4e1e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -227,5 +227,5 @@ See [api-reference.md](./api-reference.md) for the full endpoint list. ## Known Limitations -- Cancellation detection has a small lag. For non-deterministic generators, C1 catches `SingleOrderNotAuthed` on the next poll (every block). For deterministic generators, C5 reads `singleOrders(owner, hash)` every `DETERMINISTIC_CANCEL_SWEEP_INTERVAL` blocks (default 100) — so on-chain removal is reflected with worst-case latency of ~100 blocks (~20 min mainnet, ~8 min Gnosis). There is no on-chain event for `remove()`, so shorter detection latency would require a higher-cadence sweep. Once the generator is marked `Cancelled`, C2 and C3 cascade the state to children on the next block; API-terminal statuses (`fulfilled` / `unfilled` / `expired`) still win for children that were already traded on the orderbook. +- Cancellation detection has a small lag. For non-deterministic generators, C1 catches `SingleOrderNotAuthed` on the next poll (every block). For deterministic generators, C5 reads `singleOrders(owner, hash)` every `DETERMINISTIC_CANCEL_SWEEP_INTERVAL` blocks (default 100) — so on-chain removal is reflected with worst-case latency of ~100 blocks (~20 min mainnet, ~8 min Gnosis). There is no on-chain event for `remove()`, so shorter detection latency would require a higher-cadence sweep. Once the generator is marked `Cancelled`, C2 and C3 cascade the state to children on the next block. The C2 cascade does a preflight `/by_uids` query so that candidates already on the orderbook get their actual status rather than defaulting to `cancelled`; API-terminal statuses (`fulfilled` / `unfilled` / `expired`) still win for children already promoted to `discrete_order`. - Aave adapter owner resolution is reactive — `owner_mapping` is written when the adapter appears in settlement, which may be after the conditional order is created. The generator row keeps `resolvedOwner` equal to the adapter address when no mapping existed at insert time; that column is not backfilled when the mapping is inserted later. `ownerAddressType` on the generator IS backfilled when the mapping is inserted — after which GraphQL and REST filters on `ownerAddressType = "flash_loan_helper"` reflect the correct value. `resolvedOwner` is still not backfilled (set once at insert, unchanged thereafter). diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 6d600d7..d50ad12 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -15,7 +15,7 @@ */ import { ponder } from "ponder:registry"; -import { bootstrapRetryQueue, candidateDiscreteOrder, conditionalOrderGenerator, discreteOrder } from "ponder:schema"; +import { bootstrapRetryQueue, candidateDiscreteOrder, conditionalOrderGenerator, discreteOrder, discreteOrderStatusEnum } from "ponder:schema"; import { and, asc, eq, inArray, isNull, lte, or, sql } from "ponder"; import type { Hex } from "viem"; import { @@ -27,6 +27,7 @@ import { BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS, DEFAULT_MAX_GENERATORS_PER_BLOCK, DETERMINISTIC_CANCEL_SWEEP_INTERVAL, + ORDERBOOK_HTTP_TIMEOUT_MS, RECHECK_INTERVAL, TRY_NEXT_BLOCK_WARMUP_THRESHOLD, TRY_NEXT_BLOCK_COOLDOWN_THRESHOLD, @@ -42,6 +43,7 @@ import { } from "../helpers/pollResultErrors"; import { computeOrderUid, type GPv2OrderData } from "../helpers/orderUid"; import { type OrderType } from "../../utils/order-types"; +type DiscreteStatus = (typeof discreteOrderStatusEnum.enumValues)[number]; const NON_DETERMINISTIC_TYPES: readonly OrderType[] = ["PerpetualSwap", "GoodAfterTime", "TradeAboveThreshold", "Unknown"]; const SINGLE_SHOT_NON_DETERMINISTIC: readonly OrderType[] = ["GoodAfterTime", "TradeAboveThreshold"]; @@ -355,23 +357,46 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { }[]; if (orphanCandidates.length > 0) { + // COW-990: preflight /by_uids before writing cancelled. A candidate could have + // been posted by the watch-tower and filled/expired between generator creation + // and the cancellation cascade (~0.17% observed rate). Use the API status when + // available; fall back to 'cancelled' for UIDs not yet on the orderbook. + // Bounded by ORDERBOOK_HTTP_TIMEOUT_MS * 2; on timeout the empty map fallback + // keeps correctness degraded-gracefully (all orphans written as 'cancelled'). + let preflightStatuses: Awaited>; + try { + preflightStatuses = await withTimeout( + fetchOrderStatusByUids(context, chainId, orphanCandidates.map((c) => c.orderUid)), + ORDERBOOK_HTTP_TIMEOUT_MS * 2, + "c2:cascade:preflight", + ); + } catch { + preflightStatuses = new Map(); + } + + // onConflictDoNothing: if C3 already promoted this UID with a terminal status + // (e.g. 'fulfilled'), the existing row wins and this insert is a no-op. + // preflightKnown counts API hits, not rows actually written. await context.db.sql .insert(discreteOrder) .values( - orphanCandidates.map((c) => ({ - orderUid: c.orderUid, - chainId, - conditionalOrderGeneratorId: c.generatorId, - status: "cancelled" as const, - sellAmount: c.sellAmount, - buyAmount: c.buyAmount, - feeAmount: c.feeAmount, - validTo: c.validTo, - creationDate: c.creationDate, - executedSellAmount: null, - executedBuyAmount: null, - promotedAt: event.block.timestamp, - })), + orphanCandidates.map((c) => { + const apiEntry = preflightStatuses.get(c.orderUid); + return { + orderUid: c.orderUid, + chainId, + conditionalOrderGeneratorId: c.generatorId, + status: (apiEntry?.status ?? "cancelled") as DiscreteStatus, + sellAmount: c.sellAmount, + buyAmount: c.buyAmount, + feeAmount: c.feeAmount, + validTo: c.validTo, + creationDate: c.creationDate, + executedSellAmount: apiEntry?.executedSellAmount ?? null, + executedBuyAmount: apiEntry?.executedBuyAmount ?? null, + promotedAt: event.block.timestamp, + }; + }), ) .onConflictDoNothing(); @@ -387,8 +412,9 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { ), ); + const preflightKnown = preflightStatuses.size; console.log( - `[COW:C2] block=${event.block.number} chain=${chainId} parent-cancelled=${orphanCandidates.length}`, + `[COW:C2] c2:parent-cancelled block=${event.block.number} chainId=${chainId} parentCancelled=${orphanCandidates.length} preflightKnown=${preflightKnown}`, ); } } @@ -429,7 +455,6 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { const uids = unconfirmed.map((c) => c.orderUid); const statuses = await fetchOrderStatusByUids(context, chainId, uids); - type DiscreteStatus = "open" | "fulfilled" | "unfilled" | "expired" | "cancelled"; const rowsToUpsert: (typeof discreteOrder.$inferInsert)[] = []; const confirmedUids: string[] = [];