From 8e5a10c7d09efeb2091b29896b7453d6986ca2c8 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 15:47:37 -0300 Subject: [PATCH 01/84] =?UTF-8?q?fix:=20minor=20cleanups=20=E2=80=94=20MAX?= =?UTF-8?q?=5FTWAP=5FPRECOMPUTE=5FPARTS=20constant,=20decoder=20ABI=20dedu?= =?UTF-8?q?p=20(COW-1004)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lift magic literal 100000 to MAX_TWAP_PRECOMPUTE_PARTS in constants.ts; import it in uidPrecompute.ts so the threshold is documented and grep-able - Export *_ABI constants from all decoder source files; update tests/decoders/decoders.test.ts to import them instead of duplicating inline ABI fragments - Fix "mainnet and gnosis" hardcode in docs/architecture.md to say "all active chains" Co-Authored-By: Claude Sonnet 4.6 --- docs/architecture.md | 2 +- src/application/helpers/uidPrecompute.ts | 3 +- src/constants.ts | 8 +++ src/decoders/circles-backing-order.ts | 2 +- src/decoders/good-after-time.ts | 2 +- src/decoders/perpetual-swap.ts | 2 +- src/decoders/stop-loss.ts | 2 +- src/decoders/swap-order-handler.ts | 2 +- src/decoders/trade-above-threshold.ts | 2 +- src/decoders/twap.ts | 2 +- tests/decoders/decoders.test.ts | 81 ++++-------------------- 11 files changed, 32 insertions(+), 76 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index d11b3ed..0ec42b5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -226,7 +226,7 @@ See [api-reference.md](./api-reference.md) for the full endpoint list. 5. Add the RPC URL to `.env.local`. 6. Run `pnpm codegen` to regenerate types. -The block handlers (C1–C5) already run on both mainnet and gnosis. Adding a new chain requires adding entries to each block handler's `chain` config in `ponder.config.ts`. +The block handlers (C1–C5) run on all active chains (see `ponder.config.ts` for the current list). Adding a new chain requires adding entries to each block handler's `chain` config there. ## Known Limitations diff --git a/src/application/helpers/uidPrecompute.ts b/src/application/helpers/uidPrecompute.ts index 80ca37e..372be70 100644 --- a/src/application/helpers/uidPrecompute.ts +++ b/src/application/helpers/uidPrecompute.ts @@ -21,6 +21,7 @@ import { candidateDiscreteOrder, conditionalOrderGenerator, discreteOrder } from import { computeOrderUid, type GPv2OrderData } from "./orderUid"; import { fetchOrderStatusByUids } from "./orderbookClient"; import { isDeterministicOrderType } from "../../utils/order-types"; +import { MAX_TWAP_PRECOMPUTE_PARTS } from "../../constants"; // GPv2Order.sol constant hashes const KIND_SELL = "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775" as Hex; @@ -235,7 +236,7 @@ function precomputeTwapUids( console.warn(`[COW:PRECOMPUTE] SKIP type=TWAP owner=${owner} chain=${chainId} reason=invalid_math nParts=${nParts} tSeconds=${tSeconds}`); return null; } - if (nParts > 100000) { + if (nParts > MAX_TWAP_PRECOMPUTE_PARTS) { console.warn(`[COW:PRECOMPUTE] SKIP type=TWAP owner=${owner} chain=${chainId} reason=too_many_parts nParts=${nParts}`); return null; } diff --git a/src/constants.ts b/src/constants.ts index b427256..4d90739 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -78,3 +78,11 @@ export const BLOCK_HANDLER_RPC_TIMEOUT_MS = 15_000; * the normal C1 / C2 path picks them up on subsequent blocks. */ export const BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS = 30_000; + +/** + * Maximum number of TWAP parts that precomputeOrderUids will attempt to enumerate. + * Pathological orders with n > this value skip precompute and fall back to the C1 + * ContractPoller discovery path (allCandidatesKnown=false). Logged as + * [COW:PRECOMPUTE] SKIP reason=too_many_parts when triggered. + */ +export const MAX_TWAP_PRECOMPUTE_PARTS = 100_000; diff --git a/src/decoders/circles-backing-order.ts b/src/decoders/circles-backing-order.ts index e214675..a63684a 100644 --- a/src/decoders/circles-backing-order.ts +++ b/src/decoders/circles-backing-order.ts @@ -7,7 +7,7 @@ export interface CirclesBackingOrderDecodedParams { appData: string; // bytes32 } -const CIRCLES_BACKING_ORDER_ABI = [ +export const CIRCLES_BACKING_ORDER_ABI = [ { type: "tuple", components: [ diff --git a/src/decoders/good-after-time.ts b/src/decoders/good-after-time.ts index 59e81f7..b690ebf 100644 --- a/src/decoders/good-after-time.ts +++ b/src/decoders/good-after-time.ts @@ -13,7 +13,7 @@ export interface GoodAfterTimeDecodedParams { appData: string; } -const GOOD_AFTER_TIME_ABI = [ +export const GOOD_AFTER_TIME_ABI = [ { type: "tuple", components: [ diff --git a/src/decoders/perpetual-swap.ts b/src/decoders/perpetual-swap.ts index 25d919b..599e2d3 100644 --- a/src/decoders/perpetual-swap.ts +++ b/src/decoders/perpetual-swap.ts @@ -8,7 +8,7 @@ export interface PerpetualSwapDecodedParams { appData: string; } -const PERPETUAL_SWAP_ABI = [ +export const PERPETUAL_SWAP_ABI = [ { type: "tuple", components: [ diff --git a/src/decoders/stop-loss.ts b/src/decoders/stop-loss.ts index 253ae4d..4b93fed 100644 --- a/src/decoders/stop-loss.ts +++ b/src/decoders/stop-loss.ts @@ -16,7 +16,7 @@ export interface StopLossDecodedParams { maxTimeSinceLastOracleUpdate: bigint; } -const STOP_LOSS_ABI = [ +export const STOP_LOSS_ABI = [ { type: "tuple", components: [ diff --git a/src/decoders/swap-order-handler.ts b/src/decoders/swap-order-handler.ts index 8db23f7..aa79149 100644 --- a/src/decoders/swap-order-handler.ts +++ b/src/decoders/swap-order-handler.ts @@ -14,7 +14,7 @@ export interface SwapOrderHandlerDecodedParams { appData: string; // bytes32 } -const SWAP_ORDER_HANDLER_ABI = [ +export const SWAP_ORDER_HANDLER_ABI = [ { type: "tuple", components: [ diff --git a/src/decoders/trade-above-threshold.ts b/src/decoders/trade-above-threshold.ts index 2309f08..85588e0 100644 --- a/src/decoders/trade-above-threshold.ts +++ b/src/decoders/trade-above-threshold.ts @@ -9,7 +9,7 @@ export interface TradeAboveThresholdDecodedParams { appData: string; } -const TRADE_ABOVE_THRESHOLD_ABI = [ +export const TRADE_ABOVE_THRESHOLD_ABI = [ { type: "tuple", components: [ diff --git a/src/decoders/twap.ts b/src/decoders/twap.ts index 55d15db..8434102 100644 --- a/src/decoders/twap.ts +++ b/src/decoders/twap.ts @@ -13,7 +13,7 @@ export interface TwapDecodedParams { appData: string; // bytes32 } -const TWAP_ABI = [ +export const TWAP_ABI = [ { type: "tuple", components: [ diff --git a/tests/decoders/decoders.test.ts b/tests/decoders/decoders.test.ts index c5e6690..940d918 100644 --- a/tests/decoders/decoders.test.ts +++ b/tests/decoders/decoders.test.ts @@ -11,6 +11,13 @@ import { decodeErc4626CowSwapFeeBurnerStaticInput, decodeStaticInput, } from "../../src/decoders/index"; +import { TWAP_ABI } from "../../src/decoders/twap"; +import { STOP_LOSS_ABI } from "../../src/decoders/stop-loss"; +import { PERPETUAL_SWAP_ABI } from "../../src/decoders/perpetual-swap"; +import { GOOD_AFTER_TIME_ABI } from "../../src/decoders/good-after-time"; +import { TRADE_ABOVE_THRESHOLD_ABI } from "../../src/decoders/trade-above-threshold"; +import { CIRCLES_BACKING_ORDER_ABI } from "../../src/decoders/circles-backing-order"; +import { SWAP_ORDER_HANDLER_ABI } from "../../src/decoders/swap-order-handler"; const ADDR_A = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as const; const ADDR_B = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" as const; @@ -20,18 +27,7 @@ const APP_DATA = ("0x" + "ab".repeat(32)) as `0x${string}`; describe("decodeTwapStaticInput", () => { it("round-trips all fields", () => { const encoded = encodeAbiParameters( - [{ type: "tuple", components: [ - { name: "sellToken", type: "address" }, - { name: "buyToken", type: "address" }, - { name: "receiver", type: "address" }, - { name: "partSellAmount", type: "uint256" }, - { name: "minPartLimit", type: "uint256" }, - { name: "t0", type: "uint256" }, - { name: "n", type: "uint256" }, - { name: "t", type: "uint256" }, - { name: "span", type: "uint256" }, - { name: "appData", type: "bytes32" }, - ]}], + TWAP_ABI, [{ sellToken: ADDR_A, buyToken: ADDR_B, receiver: ADDR_C, partSellAmount: 1000n, minPartLimit: 900n, t0: 1700000000n, n: 6n, t: 3600n, span: 0n, appData: APP_DATA }] @@ -53,21 +49,7 @@ describe("decodeTwapStaticInput", () => { describe("decodeStopLossStaticInput", () => { it("round-trips all fields including signed strike", () => { const encoded = encodeAbiParameters( - [{ type: "tuple", components: [ - { name: "sellToken", type: "address" }, - { name: "buyToken", type: "address" }, - { name: "sellAmount", type: "uint256" }, - { name: "buyAmount", type: "uint256" }, - { name: "appData", type: "bytes32" }, - { name: "receiver", type: "address" }, - { name: "isSellOrder", type: "bool" }, - { name: "isPartiallyFillable", type: "bool" }, - { name: "validTo", type: "uint32" }, - { name: "sellTokenPriceOracle", type: "address" }, - { name: "buyTokenPriceOracle", type: "address" }, - { name: "strike", type: "int256" }, - { name: "maxTimeSinceLastOracleUpdate", type: "uint256" }, - ]}], + STOP_LOSS_ABI, [{ sellToken: ADDR_A, buyToken: ADDR_B, sellAmount: 500n, buyAmount: 400n, appData: APP_DATA, receiver: ADDR_C, isSellOrder: true, isPartiallyFillable: false, validTo: 86400, sellTokenPriceOracle: ADDR_A, @@ -86,13 +68,7 @@ describe("decodeStopLossStaticInput", () => { describe("decodePerpetualSwapStaticInput", () => { it("round-trips all fields", () => { const encoded = encodeAbiParameters( - [{ type: "tuple", components: [ - { name: "tokenA", type: "address" }, - { name: "tokenB", type: "address" }, - { name: "validityBucketSeconds", type: "uint32" }, - { name: "halfSpreadBps", type: "uint256" }, - { name: "appData", type: "bytes32" }, - ]}], + PERPETUAL_SWAP_ABI, [{ tokenA: ADDR_A, tokenB: ADDR_B, validityBucketSeconds: 900, halfSpreadBps: 5n, appData: APP_DATA }] ); @@ -108,18 +84,7 @@ describe("decodeGoodAfterTimeStaticInput", () => { it("round-trips including dynamic bytes field", () => { const payload = "0xdeadbeef" as `0x${string}`; const encoded = encodeAbiParameters( - [{ type: "tuple", components: [ - { name: "sellToken", type: "address" }, - { name: "buyToken", type: "address" }, - { name: "receiver", type: "address" }, - { name: "sellAmount", type: "uint256" }, - { name: "minSellBalance", type: "uint256" }, - { name: "startTime", type: "uint256" }, - { name: "endTime", type: "uint256" }, - { name: "allowPartialFill", type: "bool" }, - { name: "priceCheckerPayload", type: "bytes" }, - { name: "appData", type: "bytes32" }, - ]}], + GOOD_AFTER_TIME_ABI, [{ sellToken: ADDR_A, buyToken: ADDR_B, receiver: ADDR_C, sellAmount: 200n, minSellBalance: 50n, startTime: 1700000000n, endTime: 1700086400n, @@ -137,14 +102,7 @@ describe("decodeGoodAfterTimeStaticInput", () => { describe("decodeTradeAboveThresholdStaticInput", () => { it("round-trips all fields", () => { const encoded = encodeAbiParameters( - [{ type: "tuple", components: [ - { name: "sellToken", type: "address" }, - { name: "buyToken", type: "address" }, - { name: "receiver", type: "address" }, - { name: "validityBucketSeconds", type: "uint32" }, - { name: "threshold", type: "uint256" }, - { name: "appData", type: "bytes32" }, - ]}], + TRADE_ABOVE_THRESHOLD_ABI, [{ sellToken: ADDR_A, buyToken: ADDR_B, receiver: ADDR_C, validityBucketSeconds: 1800, threshold: 1000000n, appData: APP_DATA }] @@ -159,12 +117,7 @@ describe("decodeTradeAboveThresholdStaticInput", () => { describe("decodeCirclesBackingOrderStaticInput", () => { it("round-trips all four fields", () => { const encoded = encodeAbiParameters( - [{ type: "tuple", components: [ - { name: "buyToken", type: "address" }, - { name: "buyAmount", type: "uint256" }, - { name: "validTo", type: "uint32" }, - { name: "appData", type: "bytes32" }, - ]}], + CIRCLES_BACKING_ORDER_ABI, [{ buyToken: ADDR_A, buyAmount: 12345n, validTo: 1800000000, appData: APP_DATA }], ); const result = decodeCirclesBackingOrderStaticInput(encoded); @@ -182,13 +135,7 @@ describe("decodeCirclesBackingOrderStaticInput", () => { describe("decodeSwapOrderHandlerStaticInput", () => { it("round-trips all five fields", () => { const encoded = encodeAbiParameters( - [{ type: "tuple", components: [ - { name: "sellToken", type: "address" }, - { name: "buyToken", type: "address" }, - { name: "receiver", type: "address" }, - { name: "validityPeriod", type: "uint32" }, - { name: "appData", type: "bytes32" }, - ]}], + SWAP_ORDER_HANDLER_ABI, [{ sellToken: ADDR_A, buyToken: ADDR_B, receiver: ADDR_C, validityPeriod: 86400, appData: APP_DATA }], ); From 9552081c6d32f549f2e794488069a562e4acd6d9 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 16:44:05 -0300 Subject: [PATCH 02/84] fix: C3 StatusUpdater batch upsert and per-block cap (COW-988) Replace N sequential per-order update() calls with a single multi-row upsert, matching the C2 pattern. Add MAX_DISCRETE_ORDERS_PER_BLOCK cap (default 200, env-var override per chainId) to bound /by_uids batch size and keep block handler transactions short. Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/blockHandler.ts | 83 +++++++++++++++++------- src/constants.ts | 10 +++ 2 files changed, 68 insertions(+), 25 deletions(-) diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 817453c..59fd969 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -25,6 +25,7 @@ import { import { BLOCK_HANDLER_RPC_TIMEOUT_MS, BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS, + DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK, DEFAULT_MAX_GENERATORS_PER_BLOCK, DETERMINISTIC_CANCEL_SWEEP_INTERVAL, RECHECK_INTERVAL, @@ -561,9 +562,20 @@ ponder.on("StatusUpdater:block", async ({ event, context }) => { const chainId = context.chain.id as SupportedChainId; const currentTimestamp = event.block.timestamp; + const maxOrdersPerBlock = + Number(process.env[`MAX_DISCRETE_ORDERS_PER_BLOCK_${chainId}`]) || + DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK; + const openOrders = await context.db.sql .select({ orderUid: discreteOrder.orderUid, + conditionalOrderGeneratorId: discreteOrder.conditionalOrderGeneratorId, + sellAmount: discreteOrder.sellAmount, + buyAmount: discreteOrder.buyAmount, + feeAmount: discreteOrder.feeAmount, + validTo: discreteOrder.validTo, + creationDate: discreteOrder.creationDate, + promotedAt: discreteOrder.promotedAt, }) .from(discreteOrder) .where( @@ -571,39 +583,60 @@ ponder.on("StatusUpdater:block", async ({ event, context }) => { eq(discreteOrder.chainId, chainId), eq(discreteOrder.status, "open"), ), - ) as { orderUid: string }[]; + ) + .limit(maxOrdersPerBlock) as { + orderUid: string; + conditionalOrderGeneratorId: string; + sellAmount: string; + buyAmount: string; + feeAmount: string; + validTo: number | null; + creationDate: bigint; + promotedAt: bigint | null; + }[]; if (openOrders.length > 0) { const uids = openOrders.map((o) => o.orderUid); const statuses = await fetchOrderStatusByUids(context, chainId, uids); - let updated = 0; - for (const [uid, info] of statuses) { - if (VALID_DISCRETE_STATUSES.has(info.status)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const setFields: Record = { - status: info.status as "fulfilled" | "unfilled" | "expired" | "cancelled", - }; - if (info.executedSellAmount != null) { - setFields.executedSellAmount = info.executedSellAmount; - setFields.executedBuyAmount = info.executedBuyAmount; - } - await context.db.sql - .update(discreteOrder) - .set(setFields) - .where( - and( - eq(discreteOrder.chainId, chainId), - eq(discreteOrder.orderUid, uid), - ), - ); - updated++; - } + type DiscreteStatus = "open" | "fulfilled" | "unfilled" | "expired" | "cancelled"; + const rowsToUpdate: (typeof discreteOrder.$inferInsert)[] = []; + + for (const order of openOrders) { + const info = statuses.get(order.orderUid); + if (!info || !VALID_DISCRETE_STATUSES.has(info.status)) continue; + rowsToUpdate.push({ + orderUid: order.orderUid, + chainId, + conditionalOrderGeneratorId: order.conditionalOrderGeneratorId, + status: info.status as DiscreteStatus, + sellAmount: order.sellAmount, + buyAmount: order.buyAmount, + feeAmount: order.feeAmount, + validTo: order.validTo, + creationDate: order.creationDate, + executedSellAmount: info.executedSellAmount ?? null, + executedBuyAmount: info.executedBuyAmount ?? null, + promotedAt: order.promotedAt, + }); } - if (updated > 0) { + // One multi-row upsert keeps the block TX open for one round-trip instead of N. + if (rowsToUpdate.length > 0) { + await context.db.sql + .insert(discreteOrder) + .values(rowsToUpdate) + .onConflictDoUpdate({ + target: [discreteOrder.chainId, discreteOrder.orderUid], + set: { + status: sql`excluded.status`, + executedSellAmount: sql`excluded.executed_sell_amount`, + executedBuyAmount: sql`excluded.executed_buy_amount`, + }, + }); + console.log( - `[COW:C3] block=${event.block.number} chain=${chainId} open=${openOrders.length} updated=${updated}`, + `[COW:C3] block=${event.block.number} chain=${chainId} open=${openOrders.length} updated=${rowsToUpdate.length}`, ); } } diff --git a/src/constants.ts b/src/constants.ts index b427256..ee65a14 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -78,3 +78,13 @@ export const BLOCK_HANDLER_RPC_TIMEOUT_MS = 15_000; * the normal C1 / C2 path picks them up on subsequent blocks. */ export const BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS = 30_000; + +/** + * COW-988: Hard per-block ceiling on how many open discrete orders the C3 + * StatusUpdater will check in a single block. Caps the /by_uids batch size + * and keeps block handler transactions short. + * + * Override per chain with env var MAX_DISCRETE_ORDERS_PER_BLOCK_, e.g. + * MAX_DISCRETE_ORDERS_PER_BLOCK_1=200, MAX_DISCRETE_ORDERS_PER_BLOCK_100=500. + */ +export const DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK = 200; From 7970029014182d45e8a2cf9fc37fb2f5aec3b2a3 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 18:19:07 -0300 Subject: [PATCH 03/84] docs: complete COW-1004 doc updates (chain wording, TWAP skip note, GAT on-chain caveat) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace "mainnet and Gnosis Chain" with "all active chains" in architecture.md overview and supported-order-types.md header (consistent with line 229 already fixed) - Add TWAP edge-case note: n > MAX_TWAP_PRECOMPUTE_PARTS skips precompute → C1 fallback - Add GoodAfterTime caveat: decoder is unit-tested but no real on-chain order observed yet Co-Authored-By: Claude Sonnet 4.6 --- docs/architecture.md | 2 +- docs/supported-order-types.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 0ec42b5..17f434f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,7 +4,7 @@ This document covers how the indexer works, from on-chain events to the GraphQL ## Overview -The system is a Ponder 0.16.x indexer that watches the ComposableCoW contract on Ethereum mainnet and Gnosis Chain. When a user creates a programmatic order (TWAP, Stop Loss, etc.), the contract emits a `ConditionalOrderCreated` event. The indexer picks that up, decodes the order parameters, resolves the actual owner (which may be behind a proxy), and writes the result to Postgres. A Hono HTTP server exposes the data through GraphQL and a SQL passthrough endpoint. +The system is a Ponder 0.16.x indexer that watches the ComposableCoW contract on all active chains (see `ponder.config.ts`). When a user creates a programmatic order (TWAP, Stop Loss, etc.), the contract emits a `ConditionalOrderCreated` event. The indexer picks that up, decodes the order parameters, resolves the actual owner (which may be behind a proxy), and writes the result to Postgres. A Hono HTTP server exposes the data through GraphQL and a SQL passthrough endpoint. Ponder registers nine top-level handlers: four contract event handlers (`ComposableCow` backfill, `ComposableCowLive`, `CoWShedFactory`, `GPv2Settlement`) plus five live-only block handlers in `blockHandler.ts` (C1–C5). The contract handlers react to on-chain events; C1–C5 poll contract state and the orderbook API during live sync. `settlement.ts` inspects `Settlement` receipts to detect Aave adapters from Trade logs. diff --git a/docs/supported-order-types.md b/docs/supported-order-types.md index c04aa03..d64a4ed 100644 --- a/docs/supported-order-types.md +++ b/docs/supported-order-types.md @@ -2,7 +2,7 @@ The indexer decodes five programmatic order types from the ComposableCoW contract. Each order is created on-chain as a `ConditionalOrderCreated` event containing a handler address, a salt, and an opaque `staticInput` blob. The handler address determines the order type, and the `staticInput` is ABI-decoded into typed parameters stored in the `decodedParams` JSON field on `conditional_order_generator`. -All handler addresses are identical across mainnet and Gnosis Chain (CREATE2 deployments). Arbitrum support is planned but handler mappings are not yet registered. +Most handler addresses are identical across all active chains (CREATE2 deployments — see `src/utils/order-types.ts` for chain-specific overrides). Arbitrum support is planned but handler mappings are not yet registered. A note on types in the API: all `bigint` values (uint256, int256) are converted to strings via `replaceBigInts(decoded, String)` before storage. When you query `decodedParams` through GraphQL or SQL, amounts, timestamps, and similar fields come back as decimal strings, not numbers. `uint32` fields stay as JSON numbers. See [Timestamp fields](./api-reference.md#timestamp-fields) for the unit and shape policy that applies to every timestamp-like field below. @@ -60,6 +60,7 @@ TWAP generates `n` discrete orders, one per time slice. Each part covers `[t0 + - When `t0` is 0, the contract uses the block timestamp of the creation transaction as the start time. The `decodedParams` will still show `"0"` -- the actual resolved start time is not stored. - If a part's validity window passes without execution, that part is simply skipped. There is no retry or rollover. - Setting `span` shorter than `t` creates gaps where no part is active. Setting `span` longer than `t` creates overlapping validity windows. +- TWAP orders with `n` exceeding `MAX_TWAP_PRECOMPUTE_PARTS` (100 000) skip UID pre-computation and fall back to the C1 ContractPoller discovery path (`allCandidatesKnown=false`). This is logged as `[COW:PRECOMPUTE] SKIP reason=too_many_parts`. Such pathological orders are valid on-chain but are impractical in practice. --- @@ -210,6 +211,7 @@ GAT produces a single discrete order that is valid within the `[startTime, endTi - `priceCheckerPayload` is a `bytes` field (dynamic length). The decoder stores it as raw hex. Interpreting its contents requires knowledge of which price checker contract the order was configured with, which is not part of the struct itself. - If `startTime` is in the past at creation time, the order is immediately eligible (assuming the balance check passes). - If the owner's balance of `sellToken` drops below `minSellBalance` after the order becomes active, the handler will revert until the balance is restored. +- **Note:** As of the last update, no GoodAfterTime orders have been observed on-chain (live count = 0). The decoder has been unit-tested with synthetic inputs but has not been validated against a real on-chain order. If a GAT order appears in production, verify the decoded output against the raw `staticInput` as a sanity check. --- From 268ffcd600c2cc2f58e743e9b8daa5131eff7553 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 18:44:00 -0300 Subject: [PATCH 04/84] test: add constants and C3 status-filter tests (COW-988) Adds tests for DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK (200) and all other constants in src/constants.ts; also adds a pure-logic test for the VALID_DISCRETE_STATUSES filter used by the C3 StatusUpdater batch upsert. Co-Authored-By: Claude Sonnet 4.6 --- tests/constants.test.ts | 135 ++++++++++++++++++++ tests/helpers/statusFilter.test.ts | 197 +++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 tests/constants.test.ts create mode 100644 tests/helpers/statusFilter.test.ts diff --git a/tests/constants.test.ts b/tests/constants.test.ts new file mode 100644 index 0000000..1a778d9 --- /dev/null +++ b/tests/constants.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { + SIGNING_SCHEME_EIP1271, + RECHECK_INTERVAL, + TRY_NEXT_BLOCK_WARMUP_THRESHOLD, + TRY_NEXT_BLOCK_COOLDOWN_THRESHOLD, + TRY_NEXT_BLOCK_BACKOFF_WARMUP, + TRY_NEXT_BLOCK_BACKOFF_MID, + TRY_NEXT_BLOCK_BACKOFF_COLD, + DETERMINISTIC_CANCEL_SWEEP_INTERVAL, + ORDERBOOK_HTTP_TIMEOUT_MS, + BLOCK_HANDLER_RPC_TIMEOUT_MS, + BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS, + DEFAULT_MAX_GENERATORS_PER_BLOCK, + DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK, +} from "../src/constants"; + +describe("DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK (COW-988)", () => { + it("is 200", () => { + expect(DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK).toBe(200); + }); + + it("is a positive integer", () => { + expect(Number.isInteger(DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK)).toBe(true); + expect(DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK).toBeGreaterThan(0); + }); +}); + +describe("DEFAULT_MAX_GENERATORS_PER_BLOCK", () => { + it("is 200", () => { + expect(DEFAULT_MAX_GENERATORS_PER_BLOCK).toBe(200); + }); +}); + +describe("SIGNING_SCHEME_EIP1271", () => { + it('is the string "eip1271"', () => { + expect(SIGNING_SCHEME_EIP1271).toBe("eip1271"); + }); + + it('is not "erc1271" — the API uses eip1271 spelling', () => { + expect(SIGNING_SCHEME_EIP1271).not.toBe("erc1271"); + }); +}); + +describe("RECHECK_INTERVAL", () => { + it("is a bigint", () => { + expect(typeof RECHECK_INTERVAL).toBe("bigint"); + }); + + it("equals BigInt(ORDERBOOK_POLL_INTERVAL) which is 20", () => { + // ORDERBOOK_POLL_INTERVAL = 20 (from data.ts) + expect(RECHECK_INTERVAL).toBe(20n); + }); +}); + +describe("TryNextBlock backoff thresholds", () => { + it("WARMUP_THRESHOLD is 50", () => { + expect(TRY_NEXT_BLOCK_WARMUP_THRESHOLD).toBe(50); + }); + + it("COOLDOWN_THRESHOLD is 200", () => { + expect(TRY_NEXT_BLOCK_COOLDOWN_THRESHOLD).toBe(200); + }); + + it("WARMUP < COOLDOWN — thresholds are ordered correctly", () => { + expect(TRY_NEXT_BLOCK_WARMUP_THRESHOLD).toBeLessThan( + TRY_NEXT_BLOCK_COOLDOWN_THRESHOLD, + ); + }); +}); + +describe("TryNextBlock backoff block offsets", () => { + it("WARMUP backoff is 1 block", () => { + expect(TRY_NEXT_BLOCK_BACKOFF_WARMUP).toBe(1n); + }); + + it("MID backoff is 10 blocks", () => { + expect(TRY_NEXT_BLOCK_BACKOFF_MID).toBe(10n); + }); + + it("COLD backoff is 50 blocks", () => { + expect(TRY_NEXT_BLOCK_BACKOFF_COLD).toBe(50n); + }); + + it("backoff levels are strictly increasing", () => { + expect(TRY_NEXT_BLOCK_BACKOFF_WARMUP).toBeLessThan( + TRY_NEXT_BLOCK_BACKOFF_MID, + ); + expect(TRY_NEXT_BLOCK_BACKOFF_MID).toBeLessThan( + TRY_NEXT_BLOCK_BACKOFF_COLD, + ); + }); + + it("all backoff values are bigints", () => { + expect(typeof TRY_NEXT_BLOCK_BACKOFF_WARMUP).toBe("bigint"); + expect(typeof TRY_NEXT_BLOCK_BACKOFF_MID).toBe("bigint"); + expect(typeof TRY_NEXT_BLOCK_BACKOFF_COLD).toBe("bigint"); + }); +}); + +describe("DETERMINISTIC_CANCEL_SWEEP_INTERVAL", () => { + it("is 100n", () => { + expect(DETERMINISTIC_CANCEL_SWEEP_INTERVAL).toBe(100n); + }); + + it("is a bigint", () => { + expect(typeof DETERMINISTIC_CANCEL_SWEEP_INTERVAL).toBe("bigint"); + }); +}); + +describe("Timeout constants", () => { + it("ORDERBOOK_HTTP_TIMEOUT_MS is 10_000", () => { + expect(ORDERBOOK_HTTP_TIMEOUT_MS).toBe(10_000); + }); + + it("BLOCK_HANDLER_RPC_TIMEOUT_MS is 15_000", () => { + expect(BLOCK_HANDLER_RPC_TIMEOUT_MS).toBe(15_000); + }); + + it("BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS is 30_000", () => { + expect(BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS).toBe(30_000); + }); + + it("RPC timeout is shorter than bootstrap timeout — boot has more slack", () => { + expect(BLOCK_HANDLER_RPC_TIMEOUT_MS).toBeLessThan( + BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS, + ); + }); + + it("HTTP timeout is shorter than RPC timeout", () => { + expect(ORDERBOOK_HTTP_TIMEOUT_MS).toBeLessThan( + BLOCK_HANDLER_RPC_TIMEOUT_MS, + ); + }); +}); diff --git a/tests/helpers/statusFilter.test.ts b/tests/helpers/statusFilter.test.ts new file mode 100644 index 0000000..5d461bb --- /dev/null +++ b/tests/helpers/statusFilter.test.ts @@ -0,0 +1,197 @@ +/** + * Tests for the C3 StatusUpdater row-building filter logic (COW-988). + * + * blockHandler.ts defines a module-level constant: + * const VALID_DISCRETE_STATUSES = new Set(["fulfilled", "unfilled", "expired", "cancelled"]); + * + * The row loop skips any order whose API status is absent from this set. + * Because blockHandler.ts imports `ponder:registry` it cannot be imported in + * tests, so we reconstruct both the set and the filtering logic here and + * verify their behaviour directly. + */ +import { describe, it, expect } from "vitest"; + +// ── Reconstruction of VALID_DISCRETE_STATUSES ──────────────────────────────── +// Keep this in sync with the definition in src/application/handlers/blockHandler.ts. +const VALID_DISCRETE_STATUSES = new Set([ + "fulfilled", + "unfilled", + "expired", + "cancelled", +]); + +type DiscreteStatus = "open" | "fulfilled" | "unfilled" | "expired" | "cancelled"; + +interface OpenOrder { + orderUid: string; + conditionalOrderGeneratorId: string; + sellAmount: string; + buyAmount: string; + feeAmount: string; + validTo: number | null; + creationDate: bigint; + promotedAt: bigint | null; +} + +interface StatusInfo { + status: string; + executedSellAmount: string | null; + executedBuyAmount: string | null; +} + +/** + * Pure re-implementation of the row-building logic from C3 StatusUpdater. + * Returns the list of rows that would be passed to the multi-row upsert. + */ +function buildRowsToUpdate( + openOrders: OpenOrder[], + statuses: Map, + chainId: number, +): Array<{ orderUid: string; status: DiscreteStatus }> { + const rows: Array<{ orderUid: string; status: DiscreteStatus }> = []; + for (const order of openOrders) { + const info = statuses.get(order.orderUid); + if (!info || !VALID_DISCRETE_STATUSES.has(info.status)) continue; + rows.push({ + orderUid: order.orderUid, + status: info.status as DiscreteStatus, + }); + } + return rows; +} + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +function makeOrder(uid: string): OpenOrder { + return { + orderUid: uid, + conditionalOrderGeneratorId: "gen-1", + sellAmount: "1000", + buyAmount: "900", + feeAmount: "10", + validTo: 1800000000, + creationDate: 1700000000n, + promotedAt: 1700000001n, + }; +} + +// ── VALID_DISCRETE_STATUSES membership ─────────────────────────────────────── + +describe("VALID_DISCRETE_STATUSES membership", () => { + it('includes "fulfilled"', () => { + expect(VALID_DISCRETE_STATUSES.has("fulfilled")).toBe(true); + }); + + it('includes "expired"', () => { + expect(VALID_DISCRETE_STATUSES.has("expired")).toBe(true); + }); + + it('includes "cancelled"', () => { + expect(VALID_DISCRETE_STATUSES.has("cancelled")).toBe(true); + }); + + it('includes "unfilled"', () => { + expect(VALID_DISCRETE_STATUSES.has("unfilled")).toBe(true); + }); + + it('does NOT include "open" — open orders are not valid update targets', () => { + expect(VALID_DISCRETE_STATUSES.has("open")).toBe(false); + }); + + it("contains exactly four statuses", () => { + expect(VALID_DISCRETE_STATUSES.size).toBe(4); + }); +}); + +// ── Row-building filter logic ───────────────────────────────────────────────── + +describe("C3 StatusUpdater row-building filter", () => { + const CHAIN_ID = 1; + + it('includes an order whose API status is "fulfilled"', () => { + const orders = [makeOrder("uid-fulfilled")]; + const statuses = new Map([ + ["uid-fulfilled", { status: "fulfilled", executedSellAmount: "999", executedBuyAmount: "888" }], + ]); + const rows = buildRowsToUpdate(orders, statuses, CHAIN_ID); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("fulfilled"); + expect(rows[0]?.orderUid).toBe("uid-fulfilled"); + }); + + it('includes an order whose API status is "expired"', () => { + const orders = [makeOrder("uid-expired")]; + const statuses = new Map([ + ["uid-expired", { status: "expired", executedSellAmount: null, executedBuyAmount: null }], + ]); + const rows = buildRowsToUpdate(orders, statuses, CHAIN_ID); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("expired"); + }); + + it('includes an order whose API status is "cancelled"', () => { + const orders = [makeOrder("uid-cancelled")]; + const statuses = new Map([ + ["uid-cancelled", { status: "cancelled", executedSellAmount: null, executedBuyAmount: null }], + ]); + const rows = buildRowsToUpdate(orders, statuses, CHAIN_ID); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("cancelled"); + }); + + it('includes an order whose API status is "unfilled"', () => { + const orders = [makeOrder("uid-unfilled")]; + const statuses = new Map([ + ["uid-unfilled", { status: "unfilled", executedSellAmount: null, executedBuyAmount: null }], + ]); + const rows = buildRowsToUpdate(orders, statuses, CHAIN_ID); + expect(rows).toHaveLength(1); + expect(rows[0]?.status).toBe("unfilled"); + }); + + it('excludes an order whose API status is "open" — open is not a terminal status', () => { + const orders = [makeOrder("uid-open")]; + const statuses = new Map([ + ["uid-open", { status: "open", executedSellAmount: null, executedBuyAmount: null }], + ]); + const rows = buildRowsToUpdate(orders, statuses, CHAIN_ID); + expect(rows).toHaveLength(0); + }); + + it("excludes an order with no matching entry in the status map", () => { + const orders = [makeOrder("uid-missing")]; + const statuses = new Map(); // empty — nothing returned from API + const rows = buildRowsToUpdate(orders, statuses, CHAIN_ID); + expect(rows).toHaveLength(0); + }); + + it("only includes orders with valid statuses from a mixed batch", () => { + const orders = [ + makeOrder("uid-a"), // fulfilled → include + makeOrder("uid-b"), // open → exclude + makeOrder("uid-c"), // expired → include + makeOrder("uid-d"), // absent → exclude + makeOrder("uid-e"), // cancelled → include + ]; + const statuses = new Map([ + ["uid-a", { status: "fulfilled", executedSellAmount: "100", executedBuyAmount: "90" }], + ["uid-b", { status: "open", executedSellAmount: null, executedBuyAmount: null }], + ["uid-c", { status: "expired", executedSellAmount: null, executedBuyAmount: null }], + // uid-d intentionally absent + ["uid-e", { status: "cancelled", executedSellAmount: null, executedBuyAmount: null }], + ]); + const rows = buildRowsToUpdate(orders, statuses, CHAIN_ID); + const uids = rows.map((r) => r.orderUid); + expect(uids).toContain("uid-a"); + expect(uids).toContain("uid-c"); + expect(uids).toContain("uid-e"); + expect(uids).not.toContain("uid-b"); + expect(uids).not.toContain("uid-d"); + expect(rows).toHaveLength(3); + }); + + it("returns an empty array when the orders list is empty", () => { + const rows = buildRowsToUpdate([], new Map(), CHAIN_ID); + expect(rows).toHaveLength(0); + }); +}); From 5e6f1d913885c688041040622852c261c7e3df5c Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 19:17:15 -0300 Subject: [PATCH 05/84] fix: remove composableCow. prefix from block interval names (Ponder dot-namespace conflict) Ponder 0.16.x treats dots in block interval names as namespace separators, causing ponder.on('composableCow.OrderDiscoveryPoller:block') to fail validation because it tries to resolve 'composableCow' as a contract name. Renamed all five intervals to their bare semantic names (no prefix): OrderDiscoveryPoller, CandidateConfirmer, OrderStatusTracker, OwnerBackfill, CancellationWatcher. Co-Authored-By: Claude Sonnet 4.6 --- ponder.config.ts | 20 ++++++++++---------- src/application/handlers/blockHandler.ts | 10 +++++----- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/ponder.config.ts b/ponder.config.ts index 7c3db7c..7af5ba4 100644 --- a/ponder.config.ts +++ b/ponder.config.ts @@ -42,46 +42,46 @@ export default createConfig({ }, }, blocks: { - // composableCow.OrderDiscoveryPoller — RPC multicall for non-deterministic generators. + // OrderDiscoveryPoller — RPC multicall for non-deterministic generators. // Gnosis interval=4 (~20s) vs mainnet interval=1 (~12s). // The CoW watch-tower processes orders sequentially — with 1,461+ gnosis // generators, a full cycle takes many blocks. Polling every 5s gnosis block // wastes RPC calls since state rarely changes between blocks. - "composableCow.OrderDiscoveryPoller": { + "OrderDiscoveryPoller": { chain: { mainnet: { startBlock: "latest" }, gnosis: { startBlock: "latest", interval: 4 }, }, interval: 1, }, - // composableCow.CandidateConfirmer — checks API for unconfirmed candidates. - "composableCow.CandidateConfirmer": { + // CandidateConfirmer — checks API for unconfirmed candidates. + "CandidateConfirmer": { chain: { mainnet: { startBlock: "latest" }, gnosis: { startBlock: "latest" }, }, interval: 1, }, - // composableCow.OrderStatusTracker — polls API for open discrete order status. - "composableCow.OrderStatusTracker": { + // OrderStatusTracker — polls API for open discrete order status. + "OrderStatusTracker": { chain: { mainnet: { startBlock: "latest" }, gnosis: { startBlock: "latest" }, }, interval: 1, }, - // composableCow.OwnerBackfill — one-time owner fetch for non-deterministic backfill orders. - "composableCow.OwnerBackfill": { + // OwnerBackfill — one-time owner fetch for non-deterministic backfill orders. + "OwnerBackfill": { chain: { mainnet: { startBlock: "latest", endBlock: "latest" }, gnosis: { startBlock: "latest", endBlock: "latest" }, }, interval: 1, }, - // composableCow.CancellationWatcher — singleOrders() mapping read for deterministic + // CancellationWatcher — singleOrders() mapping read for deterministic // generators (allCandidatesKnown=true). Cadence per generator is // DETERMINISTIC_CANCEL_SWEEP_INTERVAL blocks; the handler itself is cheap when nothing is due. - "composableCow.CancellationWatcher": { + "CancellationWatcher": { chain: { mainnet: { startBlock: "latest" }, gnosis: { startBlock: "latest" }, diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 4ab94b4..b9c63de 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -70,7 +70,7 @@ const SINGLE_ORDERS_ABI = [ // allCandidatesKnown=false. Normally only non-deterministic types, but also // serves as fallback for deterministic types whose precompute failed. -ponder.on("composableCow.OrderDiscoveryPoller:block", async ({ event, context }) => { +ponder.on("OrderDiscoveryPoller:block", async ({ event, context }) => { if (process.env.DISABLE_POLL_RESULT_CHECK) return; const chainId = context.chain.id as SupportedChainId; @@ -296,7 +296,7 @@ ponder.on("composableCow.OrderDiscoveryPoller:block", async ({ event, context }) // Checks if candidate discrete orders exist on the Orderbook API. // When confirmed, promotes them to discreteOrder. -ponder.on("composableCow.CandidateConfirmer:block", async ({ event, context }) => { +ponder.on("CandidateConfirmer:block", async ({ event, context }) => { const chainId = context.chain.id as SupportedChainId; // Parent-cancelled cascade: candidates whose parent generator flipped to @@ -565,7 +565,7 @@ ponder.on("composableCow.CandidateConfirmer:block", async ({ event, context }) = // ─── composableCow.OrderStatusTracker ──────────────────────────────────────── // Polls the API for status updates on open discrete orders. Expires past validTo. -ponder.on("composableCow.OrderStatusTracker:block", async ({ event, context }) => { +ponder.on("OrderStatusTracker:block", async ({ event, context }) => { const chainId = context.chain.id as SupportedChainId; const currentTimestamp = event.block.timestamp; @@ -664,7 +664,7 @@ ponder.on("composableCow.OrderStatusTracker:block", async ({ event, context }) = // One-time discovery of historical discrete orders for non-deterministic // generators created during backfill. Fires once at startBlock=endBlock="latest". -ponder.on("composableCow.OwnerBackfill:block", async ({ event, context }) => { +ponder.on("OwnerBackfill:block", async ({ event, context }) => { const chainId = context.chain.id as SupportedChainId; const currentBlock = event.block.number; @@ -779,7 +779,7 @@ ponder.on("composableCow.OwnerBackfill:block", async ({ event, context }) => { // Cancelled, which lets the CandidateConfirmer/OrderStatusTracker parent-cancelled // cascade (COW-918) reconcile the child discrete / candidate rows on the next block. -ponder.on("composableCow.CancellationWatcher:block", async ({ event, context }) => { +ponder.on("CancellationWatcher:block", async ({ event, context }) => { if (process.env.DISABLE_DETERMINISTIC_CANCEL_SWEEP) return; const chainId = context.chain.id as SupportedChainId; From c6fc8413afa89d0d5a7a5f000710a71d4af9ffb8 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 10:42:32 -0300 Subject: [PATCH 06/84] fix: increase ponder container nofile ulimit to 65536 Vite's file watcher exhausts the default Docker ulimit of 1024 open files when the project includes many chain files and node_modules. Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 66bd37a..e721c4d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,10 @@ services: depends_on: postgres: condition: service_healthy + ulimits: + nofile: + soft: 65536 + hard: 65536 logging: driver: json-file options: From 42de8c5196d83dc279c8e7638611c58b914c03ef Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 11:03:37 -0300 Subject: [PATCH 07/84] fix: rename progressPct to historicalSyncProgressPct in sync-progress API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the field name self-documenting — it clearly tracks historical block-fetch progress (not handler completion), consistent with the distinction between progressPct=100 and isComplete=false. Co-Authored-By: Claude Sonnet 4.6 --- src/api/endpoints/sync-progress.ts | 4 ++-- src/api/routes.ts | 2 +- src/api/schemas/sync-progress.ts | 2 +- tests/api/sync-progress.test.ts | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/api/endpoints/sync-progress.ts b/src/api/endpoints/sync-progress.ts index c72bb35..998074e 100644 --- a/src/api/endpoints/sync-progress.ts +++ b/src/api/endpoints/sync-progress.ts @@ -56,7 +56,7 @@ export const syncProgressHandler: RouteHandler = { totalBlocks: number; processedBlocks: number; - progressPct: number; + historicalSyncProgressPct: number; isRealtime: boolean; isComplete: boolean; } @@ -72,7 +72,7 @@ export const syncProgressHandler: RouteHandler = result[chain] = { totalBlocks: t, processedBlocks: processed, - progressPct: pct, + historicalSyncProgressPct: pct, isRealtime: (isRealtime.get(chain) ?? 0) === 1, isComplete: (isComplete.get(chain) ?? 0) === 1, }; diff --git a/src/api/routes.ts b/src/api/routes.ts index cc7b777..a0a7c0f 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -35,7 +35,7 @@ export const syncProgressRoute = createRoute({ tags: ["Indexer"], summary: "Per-chain historical sync progress", description: - "Returns the indexer's historical backfill progress per chain: total blocks to process, blocks already processed, percentage complete, and whether the chain is in realtime mode. Reads from Ponder's built-in Prometheus metrics. During initial sync, progressPct will rise from 0 to 100 and isComplete will flip to true once the chain is fully caught up.", + "Returns the indexer's historical backfill progress per chain: total blocks to process, blocks already processed, percentage complete, and whether the chain is in realtime mode. Reads from Ponder's built-in Prometheus metrics. During initial sync, historicalSyncProgressPct will rise from 0 to 100 and isComplete will flip to true once the chain is fully caught up.", responses: { 200: { description: "Per-chain sync progress.", diff --git a/src/api/schemas/sync-progress.ts b/src/api/schemas/sync-progress.ts index 25f525a..a5c518d 100644 --- a/src/api/schemas/sync-progress.ts +++ b/src/api/schemas/sync-progress.ts @@ -9,7 +9,7 @@ export const ChainProgressSchema = z.object({ .number() .int() .describe("Blocks already processed (completed + served from cache)."), - progressPct: z + historicalSyncProgressPct: z .number() .describe("Completion percentage (0–100). Rounded to one decimal place."), isRealtime: z diff --git a/tests/api/sync-progress.test.ts b/tests/api/sync-progress.test.ts index 87857ad..4404ff2 100644 --- a/tests/api/sync-progress.test.ts +++ b/tests/api/sync-progress.test.ts @@ -5,7 +5,7 @@ import { syncProgressHandler } from "../../src/api/endpoints/sync-progress"; type ChainProgress = { totalBlocks: number; processedBlocks: number; - progressPct: number; + historicalSyncProgressPct: number; isRealtime: boolean; isComplete: boolean; }; @@ -84,15 +84,15 @@ describe("GET /api/sync-progress", () => { expect(body["gnosis"]!.processedBlocks).toBe(2_400_000); }); - it("computes progressPct correctly (rounded to 1 decimal)", async () => { + it("computes historicalSyncProgressPct correctly (rounded to 1 decimal)", async () => { mockFetch(SAMPLE_METRICS); const app = buildApp(); const res = await app.request("http://localhost/api/sync-progress"); const body = (await res.json()) as Record; // mainnet: 3_000_000 / 7_000_000 = 42.857... → 42.9 - expect(body["mainnet"]!.progressPct).toBe(42.9); + expect(body["mainnet"]!.historicalSyncProgressPct).toBe(42.9); // gnosis: 2_400_000 / 17_000_000 = 14.117... → 14.1 - expect(body["gnosis"]!.progressPct).toBe(14.1); + expect(body["gnosis"]!.historicalSyncProgressPct).toBe(14.1); }); it("sets isRealtime and isComplete from metrics flags", async () => { From d9aea03ac2c3dd6e8c44714da5da1b2e043fb6c9 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 16:20:55 -0300 Subject: [PATCH 08/84] fix: remove undefined variables from settlement cowLog after COW-991 decode removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COW-991 removed the decodeAbiParameters block (orderUid, sellToken, buyToken, sellAmount, buyAmount were decode-only-for-logging). COW-994's cowLog migration kept those variables in the log fields — crashing the indexer at runtime. Drop the undefined fields from the log; adapter + eoa are sufficient. Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/settlement.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/application/handlers/settlement.ts b/src/application/handlers/settlement.ts index d48d42a..4c78982 100644 --- a/src/application/handlers/settlement.ts +++ b/src/application/handlers/settlement.ts @@ -231,11 +231,6 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { chainId, adapter: ownerAddress, eoa: eoaOwner.toLowerCase(), - orderUid: String(orderUid), - sellToken: sellToken.toLowerCase(), - buyToken: buyToken.toLowerCase(), - sellAmount: String(sellAmount), - buyAmount: String(buyAmount), }); } From 13b8cbff2fd8647d30613ab74f7fdf9449005873 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:07:51 -0300 Subject: [PATCH 09/84] fix: add ORDER BY to StatusUpdater SELECT, fix Number() env check, document per-block cap (COW-988) Co-Authored-By: Claude Sonnet 4.6 --- docs/deployment.md | 1 + src/application/handlers/blockHandler.ts | 14 ++++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 29a4171..51dcd5b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -31,6 +31,7 @@ Example: `DATABASE_URL=postgresql://cow_programmatic:secretpass@localhost:5433/c | `DISABLE_POLL_RESULT_CHECK` | No | Disables the C1 ContractPoller block handler. Skips RPC multicalls for non-deterministic generators. Saves RPC calls during initial sync at the cost of not detecting poll results until re-enabled. | | `DISABLE_DETERMINISTIC_CANCEL_SWEEP` | No | Disables the C5 DeterministicCancellationSweeper. Skips periodic `singleOrders()` reads on deterministic generators. While disabled, on-chain `ComposableCoW.remove()` calls on TWAP/StopLoss/CirclesBackingOrder generators will not be detected and those generators stay `Active`. | | `MAX_GENERATORS_PER_BLOCK_` | No | Per-block cap on how many generators C1 and C5 will touch on the given chain (e.g. `MAX_GENERATORS_PER_BLOCK_1=200`, `MAX_GENERATORS_PER_BLOCK_100=400`). Default is 200. Excess generators defer to the next block, prioritized by oldest `lastCheckBlock` first. | +| `MAX_DISCRETE_ORDERS_PER_BLOCK_` | No | Per-block cap on how many open discrete orders the StatusUpdater will check on the given chain (e.g. `MAX_DISCRETE_ORDERS_PER_BLOCK_1=200`). Default is 200. Excess orders are deferred to the next block, prioritised by oldest `promotedAt` first. | | `DISABLE_SETTLEMENT_FACTORY_CHECK` | No | Skips `getCode` + `FACTORY()` RPC calls in the GPv2Settlement handler. Useful for benchmarking base sync throughput. | | `PINO_LOG_LEVEL` | No | Log verbosity: `debug`, `info`, `warn`, `error`. Defaults to Ponder's built-in default. | diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 59fd969..c9901d0 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -79,9 +79,9 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { const currentBlock = event.block.number; const currentTimestamp = event.block.timestamp; + const rawGeneratorCap = Number(process.env[`MAX_GENERATORS_PER_BLOCK_${chainId}`]); const maxGeneratorsPerBlock = - Number(process.env[`MAX_GENERATORS_PER_BLOCK_${chainId}`]) || - DEFAULT_MAX_GENERATORS_PER_BLOCK; + Number.isFinite(rawGeneratorCap) && rawGeneratorCap > 0 ? rawGeneratorCap : DEFAULT_MAX_GENERATORS_PER_BLOCK; const dueOrders = await context.db.sql .select({ @@ -562,9 +562,9 @@ ponder.on("StatusUpdater:block", async ({ event, context }) => { const chainId = context.chain.id as SupportedChainId; const currentTimestamp = event.block.timestamp; + const rawOrderCap = Number(process.env[`MAX_DISCRETE_ORDERS_PER_BLOCK_${chainId}`]); const maxOrdersPerBlock = - Number(process.env[`MAX_DISCRETE_ORDERS_PER_BLOCK_${chainId}`]) || - DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK; + Number.isFinite(rawOrderCap) && rawOrderCap > 0 ? rawOrderCap : DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK; const openOrders = await context.db.sql .select({ @@ -584,6 +584,7 @@ ponder.on("StatusUpdater:block", async ({ event, context }) => { eq(discreteOrder.status, "open"), ), ) + .orderBy(asc(discreteOrder.promotedAt)) .limit(maxOrdersPerBlock) as { orderUid: string; conditionalOrderGeneratorId: string; @@ -626,6 +627,7 @@ ponder.on("StatusUpdater:block", async ({ event, context }) => { await context.db.sql .insert(discreteOrder) .values(rowsToUpdate) + // promotedAt is intentionally omitted — preserve the original promotion timestamp across status updates. .onConflictDoUpdate({ target: [discreteOrder.chainId, discreteOrder.orderUid], set: { @@ -825,9 +827,9 @@ ponder.on("DeterministicCancellationSweeper:block", async ({ event, context }) = const currentBlock = event.block.number; + const rawGeneratorCap2 = Number(process.env[`MAX_GENERATORS_PER_BLOCK_${chainId}`]); const maxGeneratorsPerBlock = - Number(process.env[`MAX_GENERATORS_PER_BLOCK_${chainId}`]) || - DEFAULT_MAX_GENERATORS_PER_BLOCK; + Number.isFinite(rawGeneratorCap2) && rawGeneratorCap2 > 0 ? rawGeneratorCap2 : DEFAULT_MAX_GENERATORS_PER_BLOCK; const dueGenerators = await context.db.sql .select({ From 51c985073ba4a22f0f514bc3a755e1990ba1814d Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:14:22 -0300 Subject: [PATCH 10/84] =?UTF-8?q?fix:=20update=20test=20mocks=20=E2=80=94?= =?UTF-8?q?=20use=20=5F=5FmakeSelectChain,=20add=20hash=20field,=20DB-thro?= =?UTF-8?q?w=20coverage=20(COW-995)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- tests/api/execution-summary.test.ts | 7 +++++ tests/api/orders-by-owner.test.ts | 46 +++++++++++++---------------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/tests/api/execution-summary.test.ts b/tests/api/execution-summary.test.ts index edcd80c..4e725d5 100644 --- a/tests/api/execution-summary.test.ts +++ b/tests/api/execution-summary.test.ts @@ -93,4 +93,11 @@ describe("GET /api/generator/:eventId/execution-summary", () => { ); expect(res.status).toBe(400); }); + + it("returns 500 when the DB throws", async () => { + vi.mocked(db.execute).mockRejectedValueOnce(new Error("db error")); + + const res = await buildApp().request(makeUrl()); + expect(res.status).toBe(500); + }); }); diff --git a/tests/api/orders-by-owner.test.ts b/tests/api/orders-by-owner.test.ts index 9258786..3ba316a 100644 --- a/tests/api/orders-by-owner.test.ts +++ b/tests/api/orders-by-owner.test.ts @@ -50,7 +50,7 @@ describe("GeneratorSummary schema", () => { const shape = GeneratorSummary.shape; const description = shape.hash.description; expect(description).toBe( - "On-chain canonical identifier: keccak256(abi.encode(handler, salt, staticInput)). Used by ComposableCow.singleOrders(owner, hash) and remove(owner, hash).", + "On-chain canonical identifier: keccak256(abi.encode((handler, salt, staticInput))). Used by ComposableCow.singleOrders(owner, hash) and remove(owner, hash).", ); }); @@ -122,13 +122,13 @@ describe("OrdersByOwnerResponse schema", () => { // ─── Endpoint integration tests (COW-995) ─────────────────────────────────── // Mock virtual modules before any ponder-importing source files are loaded. -vi.mock("ponder:api", () => ({ db: { execute: vi.fn(), select: vi.fn() } })); +// ponder:api is resolved to tests/__mocks__/ponder-api.ts via vitest alias — no inline override needed. vi.mock("ponder:schema", () => { const ownerMapping = { owner: "owner", chainId: "chainId", address: "address" }; const conditionalOrderGenerator = { eventId: "eventId", chainId: "chainId", orderType: "orderType", owner: "owner", resolvedOwner: "resolvedOwner", status: "status", - ownerAddressType: "ownerAddressType", + ownerAddressType: "ownerAddressType", hash: "hash", }; const discreteOrder = { conditionalOrderGeneratorId: "conditionalOrderGeneratorId", @@ -188,12 +188,6 @@ function makeContext({ }; } -function makeChain(rows: unknown[]) { - const where = vi.fn().mockResolvedValue(rows); - const from = vi.fn().mockReturnValue({ where }); - return { from }; -} - const GENERATOR = { eventId: EVENT_ID, chainId: CHAIN_ID, @@ -226,8 +220,8 @@ beforeEach(() => { describe("ordersByOwnerHandler", () => { it("returns empty orders array when no generators are found", async () => { vi.mocked(db.select) - .mockReturnValueOnce(makeChain([]) as never) - .mockReturnValueOnce(makeChain([]) as never); + .mockReturnValueOnce(db.__makeSelectChain([]) as never) + .mockReturnValueOnce(db.__makeSelectChain([]) as never); const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); @@ -237,9 +231,9 @@ describe("ordersByOwnerHandler", () => { it("returns empty orders when generators exist but have no discrete orders", async () => { vi.mocked(db.select) - .mockReturnValueOnce(makeChain([]) as never) - .mockReturnValueOnce(makeChain([GENERATOR]) as never) - .mockReturnValueOnce(makeChain([]) as never); + .mockReturnValueOnce(db.__makeSelectChain([]) as never) + .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(db.__makeSelectChain([]) as never); const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); @@ -249,9 +243,9 @@ describe("ordersByOwnerHandler", () => { it("returns enriched orders with embedded generator data", async () => { vi.mocked(db.select) - .mockReturnValueOnce(makeChain([]) as never) - .mockReturnValueOnce(makeChain([GENERATOR]) as never) - .mockReturnValueOnce(makeChain([ORDER]) as never); + .mockReturnValueOnce(db.__makeSelectChain([]) as never) + .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(db.__makeSelectChain([ORDER]) as never); const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); @@ -268,9 +262,9 @@ describe("ordersByOwnerHandler", () => { it("serialises creationDate as a decimal string (BigInt scalar)", async () => { vi.mocked(db.select) - .mockReturnValueOnce(makeChain([]) as never) - .mockReturnValueOnce(makeChain([GENERATOR]) as never) - .mockReturnValueOnce(makeChain([ORDER]) as never); + .mockReturnValueOnce(db.__makeSelectChain([]) as never) + .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(db.__makeSelectChain([ORDER]) as never); const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); @@ -281,9 +275,9 @@ describe("ordersByOwnerHandler", () => { it("includes proxy addresses from ownerMapping in the generator lookup", async () => { const PROXY = "0xcccccccccccccccccccccccccccccccccccccccc"; vi.mocked(db.select) - .mockReturnValueOnce(makeChain([{ address: PROXY }]) as never) - .mockReturnValueOnce(makeChain([GENERATOR]) as never) - .mockReturnValueOnce(makeChain([ORDER]) as never); + .mockReturnValueOnce(db.__makeSelectChain([{ address: PROXY }]) as never) + .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(db.__makeSelectChain([ORDER]) as never); const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); @@ -293,9 +287,9 @@ describe("ordersByOwnerHandler", () => { it("includes hash in generator object (COW-993)", async () => { vi.mocked(db.select) - .mockReturnValueOnce(makeChain([]) as never) - .mockReturnValueOnce(makeChain([GENERATOR]) as never) - .mockReturnValueOnce(makeChain([ORDER]) as never); + .mockReturnValueOnce(db.__makeSelectChain([]) as never) + .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(db.__makeSelectChain([ORDER]) as never); const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); From 40917bcaefb7d531233a3d0f0246b255355870cf Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:08:28 -0300 Subject: [PATCH 11/84] fix: update stale C1/C5 names in comments, verify CirclesBackingOrder precompute (COW-996/999/1003) Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/composableCow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/application/handlers/composableCow.ts b/src/application/handlers/composableCow.ts index ced8075..4aa348f 100644 --- a/src/application/handlers/composableCow.ts +++ b/src/application/handlers/composableCow.ts @@ -25,7 +25,7 @@ * This affects only EIP-1271 composable orders where the user cancels through * the API rather than calling ComposableCoW.remove() on-chain. In practice * this is rare — the standard on-chain cancellation path is detected via - * SingleOrderNotAuthed (C1 block handler) and the C5 singleOrders() sweep, + * SingleOrderNotAuthed (OrderDiscoveryPoller) and the CancellationWatcher, * both of which work correctly. * * If this gap proves significant in production, a lightweight periodic check From 1cadee06832031ef8939634c5fd70f70ae4c688d Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:05:53 -0300 Subject: [PATCH 12/84] fix: correct hash field describe() to use tuple abi.encode notation (COW-993) Co-Authored-By: Claude Sonnet 4.6 --- src/api/schemas/orders-by-owner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/schemas/orders-by-owner.ts b/src/api/schemas/orders-by-owner.ts index 1f132ce..e9e5c85 100644 --- a/src/api/schemas/orders-by-owner.ts +++ b/src/api/schemas/orders-by-owner.ts @@ -22,7 +22,7 @@ export const GeneratorSummary = z.object({ hash: z .string() .describe( - "On-chain canonical identifier: keccak256(abi.encode(handler, salt, staticInput)). Used by ComposableCow.singleOrders(owner, hash) and remove(owner, hash).", + "On-chain canonical identifier: keccak256(abi.encode((handler, salt, staticInput))). Used by ComposableCow.singleOrders(owner, hash) and remove(owner, hash).", ), ownerAddressType: z .enum(["cowshed_proxy", "flash_loan_helper"]) From 00cb7c3af3b5dcfae98913f396abcd2c68f8c44a Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:06:18 -0300 Subject: [PATCH 13/84] fix: update stale C1/C5 name references in constants.ts and docs (COW-1000) Co-Authored-By: Claude Sonnet 4.6 --- docs/api-reference.md | 2 +- docs/deployment.md | 6 +++--- src/constants.ts | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index e31acaf..e3a7139 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -126,7 +126,7 @@ There is one principled exception to "everything as string": `discreteOrder.vali | `discreteOrder.validTo` | number | yes | Unix seconds when this discrete order expires. `uint32` per CoW protocol. | | `discreteOrder.creationDate` | string | no | Unix seconds when the discrete order was first observed. Source varies — see the GraphQL field doc. | | `candidateDiscreteOrder.validTo` | number | yes | Same as `discreteOrder.validTo`. | -| `candidateDiscreteOrder.creationDate` | string | no | Block timestamp at C1 discovery. | +| `candidateDiscreteOrder.creationDate` | string | no | Block timestamp at **OrderDiscoveryPoller** discovery. | | `candidateDiscreteOrder.possibleValidAfterTimestamp` | string | yes | TWAP only: `t0 + partIndex*t`. Earliest Unix-seconds time the part can be valid. | ### Timestamp-like values inside `decodedParams` diff --git a/docs/deployment.md b/docs/deployment.md index e5601c6..b80475f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -30,9 +30,9 @@ Example: `DATABASE_URL=postgresql://cow_programmatic:secretpass@localhost:5433/c | Variable | Required | Description | |----------|----------|-------------| -| `DISABLE_POLL_RESULT_CHECK` | No | Disables the C1 ContractPoller block handler. Skips RPC multicalls for non-deterministic generators. Saves RPC calls during initial sync at the cost of not detecting poll results until re-enabled. | -| `DISABLE_DETERMINISTIC_CANCEL_SWEEP` | No | Disables the C5 DeterministicCancellationSweeper. Skips periodic `singleOrders()` reads on deterministic generators. While disabled, on-chain `ComposableCoW.remove()` calls on TWAP/StopLoss/CirclesBackingOrder generators will not be detected and those generators stay `Active`. | -| `MAX_GENERATORS_PER_BLOCK_` | No | Per-block cap on how many generators C1 and C5 will touch on the given chain (e.g. `MAX_GENERATORS_PER_BLOCK_1=200`, `MAX_GENERATORS_PER_BLOCK_100=400`). Default is 200. Excess generators defer to the next block, prioritized by oldest `lastCheckBlock` first. | +| `DISABLE_POLL_RESULT_CHECK` | No | Disables the OrderDiscoveryPoller block handler. Skips RPC multicalls for non-deterministic generators. Saves RPC calls during initial sync at the cost of not detecting poll results until re-enabled. | +| `DISABLE_DETERMINISTIC_CANCEL_SWEEP` | No | Disables the CancellationWatcher. Skips periodic `singleOrders()` reads on deterministic generators. While disabled, on-chain `ComposableCoW.remove()` calls on TWAP/StopLoss/CirclesBackingOrder generators will not be detected and those generators stay `Active`. | +| `MAX_GENERATORS_PER_BLOCK_` | No | Per-block cap on how many generators OrderDiscoveryPoller and CancellationWatcher will touch on the given chain (e.g. `MAX_GENERATORS_PER_BLOCK_1=200`, `MAX_GENERATORS_PER_BLOCK_100=400`). Default is 200. Excess generators defer to the next block, prioritized by oldest `lastCheckBlock` first. | | `DISABLE_SETTLEMENT_FACTORY_CHECK` | No | Skips `getCode` + `FACTORY()` RPC calls in the GPv2Settlement handler. Useful for benchmarking base sync throughput. | | `PINO_LOG_LEVEL` | No | Log verbosity: `debug`, `info`, `warn`, `error`. Defaults to Ponder's built-in default. | diff --git a/src/constants.ts b/src/constants.ts index 4d90739..42f92e2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -19,7 +19,7 @@ export const RECHECK_INTERVAL = BigInt(ORDERBOOK_POLL_INTERVAL); export const SIGNING_SCHEME_EIP1271 = "eip1271"; /** - * COW-908: Hard per-block ceiling on how many generators the C1 ContractPoller + * COW-908: Hard per-block ceiling on how many generators the OrderDiscoveryPoller * will multicall in a single block. Generators exceeding the cap defer to the * next block (prioritized by oldest lastCheckBlock first). * @@ -47,13 +47,13 @@ export const TRY_NEXT_BLOCK_BACKOFF_MID = 10n; export const TRY_NEXT_BLOCK_BACKOFF_COLD = 50n; /** - * C5 (DeterministicCancellationSweeper) re-check cadence, in blocks. + * CancellationWatcher re-check cadence, in blocks. * * For deterministic generators (`allCandidatesKnown = true`), `remove()` detection * is via a `ComposableCoW.singleOrders(owner, hash)` storage read. `remove()` is * rare; a ~100 block cadence gives a worst-case detection lag of ~20 min on - * mainnet and ~8 min on Gnosis while keeping the RPC cost well below C1's - * every-block poll. + * mainnet and ~8 min on Gnosis while keeping the RPC cost well below + * OrderDiscoveryPoller's every-block poll. */ export const DETERMINISTIC_CANCEL_SWEEP_INTERVAL = 100n; @@ -67,15 +67,15 @@ export const ORDERBOOK_HTTP_TIMEOUT_MS = 10_000; /** * Hard wall-clock cap for a block handler's aggregate `context.client.multicall` - * call (C1, C5). viem has no per-call signal; the timer races the promise and + * call (OrderDiscoveryPoller, CancellationWatcher). viem has no per-call signal; the timer races the promise and * the handler returns cleanly on breach. */ export const BLOCK_HANDLER_RPC_TIMEOUT_MS = 15_000; /** - * Hard wall-clock cap for the whole per-owner bootstrap fetch in C4 + * Hard wall-clock cap for the whole per-owner bootstrap fetch in OwnerBackfill * (account pagination + by_uids refresh). Owners that exceed this are skipped; - * the normal C1 / C2 path picks them up on subsequent blocks. + * the normal OrderDiscoveryPoller / CandidateConfirmer path picks them up on subsequent blocks. */ export const BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS = 30_000; From 9ec9634cfb6652028b14195de5035ee9dfc6a3bf Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:05:57 -0300 Subject: [PATCH 14/84] fix: use tighter 5s timeout for inner-loop RPC calls in settlement handler (COW-991) Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/settlement.ts | 6 +++--- src/constants.ts | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/application/handlers/settlement.ts b/src/application/handlers/settlement.ts index 4c78982..5f7ec89 100644 --- a/src/application/handlers/settlement.ts +++ b/src/application/handlers/settlement.ts @@ -8,7 +8,7 @@ import { AAVE_V3_ADAPTER_FACTORY_ADDRESSES, GPV2_SETTLEMENT_DEPLOYMENTS, } from "../../data"; -import { BLOCK_HANDLER_RPC_TIMEOUT_MS } from "../../constants"; +import { BLOCK_HANDLER_RPC_TIMEOUT_MS, SETTLEMENT_INNER_RPC_TIMEOUT_MS } from "../../constants"; import { TimeoutError, withTimeout } from "../helpers/withTimeout"; // Trade(address,address,address,uint256,uint256,uint256,bytes) — topic0 hash @@ -124,7 +124,7 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { try { code = await withTimeout( context.client.getCode({ address: owner }), - BLOCK_HANDLER_RPC_TIMEOUT_MS, + SETTLEMENT_INNER_RPC_TIMEOUT_MS, "settlement:getCode", ); } catch (err) { @@ -145,7 +145,7 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { try { const result = await withTimeout( context.client.call({ to: owner, data: FACTORY_SELECTOR }), - BLOCK_HANDLER_RPC_TIMEOUT_MS, + SETTLEMENT_INNER_RPC_TIMEOUT_MS, "settlement:call:FACTORY", ); factoryData = result.data; diff --git a/src/constants.ts b/src/constants.ts index 42f92e2..1e387c9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -72,6 +72,10 @@ export const ORDERBOOK_HTTP_TIMEOUT_MS = 10_000; */ export const BLOCK_HANDLER_RPC_TIMEOUT_MS = 15_000; +// Tighter cap for cheap inner-loop calls (getCode, eth_call) in the settlement handler. +// The outer receipt fetch and readContract(owner()) keep the full 15 s. +export const SETTLEMENT_INNER_RPC_TIMEOUT_MS = 5_000; + /** * Hard wall-clock cap for the whole per-owner bootstrap fetch in OwnerBackfill * (account pagination + by_uids refresh). Owners that exceed this are skipped; From 0e72d2bf38ccb993e64a4fc2f85270a51641c75c Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:07:35 -0300 Subject: [PATCH 15/84] fix: fix preflight timeout, migrate console.log to cowLog, deduplicate DiscreteStatus type (COW-990) Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/blockHandler.ts | 15 ++++++++++----- src/application/helpers/cowLogger.ts | 7 ++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index b9c63de..1299856 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -44,6 +44,8 @@ import { import { computeOrderUid, type GPv2OrderData } from "../helpers/orderUid"; import { cowLog } from "../helpers/cowLogger"; +type DiscreteStatus = "open" | "fulfilled" | "unfilled" | "expired" | "cancelled"; + const NON_DETERMINISTIC_TYPES = ["PerpetualSwap", "GoodAfterTime", "TradeAboveThreshold", "Unknown"] as const; const SINGLE_SHOT_NON_DETERMINISTIC = ["GoodAfterTime", "TradeAboveThreshold"] as const; const BLOCK_NEVER = 2n ** 63n - 1n; // sentinel for epoch-scheduled generators (PollTryAtEpoch) @@ -350,12 +352,11 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { // 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. - type DiscreteStatus = "open" | "fulfilled" | "unfilled" | "expired" | "cancelled"; let preflightStatuses: Awaited>; try { preflightStatuses = await withTimeout( fetchOrderStatusByUids(context, chainId, orphanCandidates.map((c) => c.orderUid)), - ORDERBOOK_HTTP_TIMEOUT_MS, + ORDERBOOK_HTTP_TIMEOUT_MS * 2, "c2:cascade:preflight", ); } catch { @@ -397,8 +398,13 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { ), ); - const preflightHits = preflightStatuses.size; - cowLog("info", "CandidateConfirmer:parent_cancelled", { block: String(event.block.number), chainId, parentCancelled: orphanCandidates.length, preflightHits }); + const preflightKnown = preflightStatuses.size; + cowLog("info", "CandidateConfirmer:parent_cancelled", { + block: String(event.block.number), + chainId, + parentCancelled: orphanCandidates.length, + preflightKnown, + }); } } @@ -438,7 +444,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[] = []; diff --git a/src/application/helpers/cowLogger.ts b/src/application/helpers/cowLogger.ts index 5a2fbaa..3969837 100644 --- a/src/application/helpers/cowLogger.ts +++ b/src/application/helpers/cowLogger.ts @@ -15,5 +15,10 @@ export function cowLog( msg: string, fields: Record = {}, ): void { - console.log(JSON.stringify({ time: Date.now(), level, msg, ...fields })); + const line = JSON.stringify({ time: Date.now(), level, msg, ...fields }); + if (level === "warn" || level === "error") { + console.error(line); + } else { + console.log(line); + } } From f04b2327ef8beff531636dbd3d7fed6292c35f39 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:07:36 -0300 Subject: [PATCH 16/84] fix: route warn/error to stderr in cowLog, add initialDelaySeconds to K8s probes (COW-994) Co-Authored-By: Claude Sonnet 4.6 --- docs/deployment.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/deployment.md b/docs/deployment.md index b80475f..e3dc269 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -155,12 +155,14 @@ livenessProbe: httpGet: path: /healthz port: 3000 + initialDelaySeconds: 30 periodSeconds: 30 failureThreshold: 3 readinessProbe: httpGet: path: /ready port: 3000 + initialDelaySeconds: 30 periodSeconds: 10 failureThreshold: 18 # 3-minute window before marking unready ``` From 67fc6ced074a452fcc99a40c2863f8ce0d7bd0d7 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:26:06 -0300 Subject: [PATCH 17/84] fix: resolve __makeSelectChain TypeScript type error in orders-by-owner tests Co-Authored-By: Claude Sonnet 4.6 --- tests/api/orders-by-owner.test.ts | 39 +++++++++++++++++-------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/tests/api/orders-by-owner.test.ts b/tests/api/orders-by-owner.test.ts index 3ba316a..25766a5 100644 --- a/tests/api/orders-by-owner.test.ts +++ b/tests/api/orders-by-owner.test.ts @@ -106,7 +106,7 @@ describe("OrdersByOwnerResponse schema", () => { expect(result.success).toBe(true); if (result.success) { expect(result.data.orders).toHaveLength(1); - expect(result.data.orders[0].generator?.hash).toBe(validGenerator.hash); + expect(result.data.orders[0]!.generator?.hash).toBe(validGenerator.hash); } }); @@ -154,6 +154,9 @@ vi.mock("ponder", () => ({ import { db } from "ponder:api"; import { ordersByOwnerHandler } from "../../src/api/endpoints/orders-by-owner"; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const makeSelectChain = (db as any).__makeSelectChain as (rows?: unknown[]) => ReturnType; + const OWNER = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const EVENT_ID = "abc123"; const CHAIN_ID = 1; @@ -220,8 +223,8 @@ beforeEach(() => { describe("ordersByOwnerHandler", () => { it("returns empty orders array when no generators are found", async () => { vi.mocked(db.select) - .mockReturnValueOnce(db.__makeSelectChain([]) as never) - .mockReturnValueOnce(db.__makeSelectChain([]) as never); + .mockReturnValueOnce(makeSelectChain([]) as never) + .mockReturnValueOnce(makeSelectChain([]) as never); const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); @@ -231,9 +234,9 @@ describe("ordersByOwnerHandler", () => { it("returns empty orders when generators exist but have no discrete orders", async () => { vi.mocked(db.select) - .mockReturnValueOnce(db.__makeSelectChain([]) as never) - .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) - .mockReturnValueOnce(db.__makeSelectChain([]) as never); + .mockReturnValueOnce(makeSelectChain([]) as never) + .mockReturnValueOnce(makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(makeSelectChain([]) as never); const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); @@ -243,9 +246,9 @@ describe("ordersByOwnerHandler", () => { it("returns enriched orders with embedded generator data", async () => { vi.mocked(db.select) - .mockReturnValueOnce(db.__makeSelectChain([]) as never) - .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) - .mockReturnValueOnce(db.__makeSelectChain([ORDER]) as never); + .mockReturnValueOnce(makeSelectChain([]) as never) + .mockReturnValueOnce(makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(makeSelectChain([ORDER]) as never); const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); @@ -262,9 +265,9 @@ describe("ordersByOwnerHandler", () => { it("serialises creationDate as a decimal string (BigInt scalar)", async () => { vi.mocked(db.select) - .mockReturnValueOnce(db.__makeSelectChain([]) as never) - .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) - .mockReturnValueOnce(db.__makeSelectChain([ORDER]) as never); + .mockReturnValueOnce(makeSelectChain([]) as never) + .mockReturnValueOnce(makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(makeSelectChain([ORDER]) as never); const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); @@ -275,9 +278,9 @@ describe("ordersByOwnerHandler", () => { it("includes proxy addresses from ownerMapping in the generator lookup", async () => { const PROXY = "0xcccccccccccccccccccccccccccccccccccccccc"; vi.mocked(db.select) - .mockReturnValueOnce(db.__makeSelectChain([{ address: PROXY }]) as never) - .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) - .mockReturnValueOnce(db.__makeSelectChain([ORDER]) as never); + .mockReturnValueOnce(makeSelectChain([{ address: PROXY }]) as never) + .mockReturnValueOnce(makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(makeSelectChain([ORDER]) as never); const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); @@ -287,9 +290,9 @@ describe("ordersByOwnerHandler", () => { it("includes hash in generator object (COW-993)", async () => { vi.mocked(db.select) - .mockReturnValueOnce(db.__makeSelectChain([]) as never) - .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) - .mockReturnValueOnce(db.__makeSelectChain([ORDER]) as never); + .mockReturnValueOnce(makeSelectChain([]) as never) + .mockReturnValueOnce(makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(makeSelectChain([ORDER]) as never); const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); From 00b2fada303c9ef712452ed25a55533f5653ba2d Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 18:34:46 -0300 Subject: [PATCH 18/84] fix: filter nullish items from fetchOrdersByUids response to prevent crash on malformed entries Co-Authored-By: Claude Sonnet 4.6 --- src/application/helpers/orderbookClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/application/helpers/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index 8962a3e..ee27453 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -365,7 +365,7 @@ async function fetchOrdersByUids( continue; } const raw = (await response.json()) as { order: OrderbookOrder }[]; - results.push(...raw.map((item) => item.order)); + results.push(...raw.flatMap((item) => (item?.order != null ? [item.order] : []))); } catch (err) { if (err instanceof TimeoutError) { console.warn( From 9ff47c3ba02ae032042b3daf090f83df0c9f9ea7 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 8 Jun 2026 10:28:46 -0300 Subject: [PATCH 19/84] docs: add MAX_DISCRETE_ORDERS_PER_BLOCK to .env.example (COW-988) Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.env.example b/.env.example index 791e5cc..0e3300a 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,11 @@ DATABASE_SCHEMA=programmatic_orders # MAX_GENERATORS_PER_BLOCK_1=200 # mainnet # MAX_GENERATORS_PER_BLOCK_100=400 # gnosis (shorter block time → higher budget) +# Per-block cap on how many open discrete orders StatusUpdater (C3) checks per chain. +# Default: 200. Excess orders defer to next block, prioritized by oldest promotedAt first. +# MAX_DISCRETE_ORDERS_PER_BLOCK_1=200 +# MAX_DISCRETE_ORDERS_PER_BLOCK_100=400 + # Logging (optional) # PINO_LOG_LEVEL=info From c882a3b488405a622d18b5ece618be809656beb6 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 9 Jun 2026 17:42:21 -0300 Subject: [PATCH 20/84] fix: chunk orphan-candidate cascade INSERT/DELETE to avoid Postgres bind-message limit Generators cancelled during historical replay can accumulate thousands of orphan candidates. A single VALUES(...) INSERT with ~17k rows exceeded Drizzle/Postgres bind-message limits, crashing CandidateConfirmer on every block and causing 200+ container restarts. Chunk both the INSERT and the inArray DELETE in CASCADE_CHUNK_SIZE=500 batches. Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/blockHandler.ts | 69 +++++++++++++----------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 9f45150..b754b23 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -368,41 +368,46 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { // 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. + // Chunked to avoid PostgreSQL bind-message parameter limits on large cascades. // preflightKnown counts API hits, not rows actually written. - await context.db.sql - .insert(discreteOrder) - .values( - 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(); + const CASCADE_CHUNK_SIZE = 500; + for (let i = 0; i < orphanCandidates.length; i += CASCADE_CHUNK_SIZE) { + const chunk = orphanCandidates.slice(i, i + CASCADE_CHUNK_SIZE); + await context.db.sql + .insert(discreteOrder) + .values( + chunk.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(); - await context.db.sql - .delete(candidateDiscreteOrder) - .where( - and( - eq(candidateDiscreteOrder.chainId, chainId), - inArray( - candidateDiscreteOrder.orderUid, - orphanCandidates.map((c) => c.orderUid), + await context.db.sql + .delete(candidateDiscreteOrder) + .where( + and( + eq(candidateDiscreteOrder.chainId, chainId), + inArray( + candidateDiscreteOrder.orderUid, + chunk.map((c) => c.orderUid), + ), ), - ), - ); + ); + } const preflightKnown = preflightStatuses.size; log("info", "CandidateConfirmer:parent_cancelled", { block: String(event.block.number), chainId, parentCancelled: orphanCandidates.length, preflightKnown }); From 3e060e50ad48c5b3eef4ba073a732150c0de096d Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 9 Jun 2026 18:14:57 -0300 Subject: [PATCH 21/84] fix(deploy): auto-drop app schema in cmdUp to clear Ponder build_id conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ponder 0.16.x computes a build_id from config + schema + handler source. Any handler change produces a new build_id, causing the next deploy to fail with "Schema was previously used by a different Ponder app". cmdUp now stops the ponder container, drops the programmatic_orders schema via docker exec on the DB container, then builds and starts the new image. ponder_sync and cow_cache live in separate schemas and are untouched — reindex reuses the local event cache (~minutes, not hours). Co-Authored-By: Claude Sonnet 4.6 --- deployment/manage.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/deployment/manage.ts b/deployment/manage.ts index 78e34df..f967662 100644 --- a/deployment/manage.ts +++ b/deployment/manage.ts @@ -118,6 +118,20 @@ function cmdUp(projectPrefix: string, revision: string): void { process.exit(1); } + // Stop the ponder container before touching the schema (leave DB running). + run("docker", ["compose", "-p", projectPrefix, "-f", "../docker-compose.yml", "--profile", "deploy", "stop", "ponder"], { ignoreError: true }); + + // Drop the app schema so the new build_id (derived from handler + config + + // schema source) is accepted without a "previously used by a different Ponder + // app" error. ponder_sync (raw-event log cache) and cow_cache live in + // separate schemas and are unaffected — reindex reuses cached RPC data + // (~minutes, not hours). + const dbContainer = `${projectPrefix}-db`; + const pgUser = process.env["POSTGRES_USER"] ?? "postgres"; + const pgDb = process.env["POSTGRES_DB"] ?? "cow_programmatic"; + const schema = process.env["DATABASE_SCHEMA"] ?? "programmatic_orders"; + run("docker", ["exec", dbContainer, "psql", "-U", pgUser, "-d", pgDb, "-c", `DROP SCHEMA IF EXISTS "${schema}" CASCADE`], { ignoreError: true }); + run("docker", [ "compose", "-p", @@ -193,7 +207,6 @@ const { command, envFile, revision } = parseArgs(process.argv.slice(2)); loadEnvFile(envFile); -// Hardcoded per project convention process.env["DATABASE_SCHEMA"] = "programmatic_orders"; const projectPrefix = process.env["PROJECT_PREFIX"]; From 51faf0e259d7bef03d2afb19350ccc1bd5641c36 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 9 Jun 2026 19:16:34 -0300 Subject: [PATCH 22/84] fix: exclude stale candidates from unconfirmed /by_uids batch; document backfill behavior (COW-992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents redundant /by_uids calls for already-expired candidates on every block during post-backfill catch-up. The stale path (account fallback, 500/block) already handles these — excluding them from the unconfirmed SELECT eliminates wasted API quota until the drain completes. Also documents cold-start duration, /ready semantics, and the historical discrete-order gap for integrators in docs/deployment.md. Co-Authored-By: Claude Sonnet 4.6 --- docs/deployment.md | 33 ++++++++++++++++++++++++ src/application/handlers/blockHandler.ts | 10 ++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/deployment.md b/docs/deployment.md index 27159f2..97cc66c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -118,3 +118,36 @@ On the target machine, you need Docker and DNS configured to point at the contai To tear down: `npx tsx deployment/manage.ts down --env-file deployment/.env` +## Cold-Start and Backfill Behavior + +### Timeline + +A fresh deployment (no prior `ponder_sync` cache) reindexes from the configured start blocks. Expected durations: + +| Phase | Typical duration | Notes | +|-------|-----------------|-------| +| Event backfill | 4–10 hours | Fetches `eth_getLogs` from start block to tip. Bottleneck is RPC throughput; a generous RPC endpoint shortens this. | +| Live-sync catch-up | 5–15 minutes | Block handlers (C1–C5) run at "latest" only. Stale TWAP candidates drain at 500/block. | +| Full data completeness | After live-sync catch-up | All generators have candidates or discrete orders; historical TWAP parts resolved via account fallback. | + +A reindex that reuses an existing `ponder_sync` cache (same chain, same start blocks) skips the event backfill and completes in minutes. + +### `/ready` Semantics + +`GET /ready` returns `200` when Ponder has processed all historical blocks up to the tip and the live indexer is running. It does **not** guarantee that all historical discrete-order data is complete — that depends on the live-sync catch-up phase completing (see above). + +During backfill, `GET /ready` returns `503`. GraphQL queries are still available but data is incomplete (generators and transactions accumulate; discrete orders are absent until live sync starts). + +### Historical Discrete Order Gap + +Block handlers only run during live sync. TWAP parts computed during backfill land in `candidate_discrete_order` with past `validTo` dates. When live sync starts, C2 (CandidateConfirmer) promotes these via the stale sweep path: + +1. Tries `/orders/by_uids` — aged-out UIDs return empty +2. Falls back to `/account/{owner}/orders` for each owner with missed UIDs +3. Promotes with the actual API status (fulfilled/expired/cancelled) instead of defaulting to `expired` + +**Residual gap**: Orders that no longer appear in `/account/{owner}/orders` (beyond the CoW API's retention window) will be recorded as `expired` regardless of their actual fill status. This affects only very old orders for users with a large order history. + +Non-deterministic generators (PerpetualSwap, GoodAfterTime, TradeAboveThreshold, Unknown) are handled by C4 (OwnerBackfill), which calls `/account/{owner}/orders` once at live-sync start and upserts discovered orders directly into `discrete_order`. + + diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index dc5c822..e8abfb9 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -16,7 +16,7 @@ import { ponder } from "ponder:registry"; import { bootstrapRetryQueue, candidateDiscreteOrder, conditionalOrderGenerator, discreteOrder, discreteOrderStatusEnum } from "ponder:schema"; -import { and, asc, eq, inArray, isNull, lte, or, sql } from "ponder"; +import { and, asc, eq, gt, inArray, isNull, lte, or, sql } from "ponder"; import type { Hex } from "viem"; import { COMPOSABLE_COW_ADDRESS_BY_CHAIN_ID, @@ -410,6 +410,10 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { // Promoted candidates are always deleted below — no join needed to filter them. // Skip TWAP parts whose validity window hasn't started (possibleValidAfterTimestamp). + // Also exclude already-expired candidates (validTo in the past) — the stale path + // below handles those via /account/{owner}/orders fallback. Without this filter, + // every block would call /by_uids for all remaining stale UIDs (which always miss), + // wasting API quota until the stale drain completes. const unconfirmed = await context.db.sql .select({ orderUid: candidateDiscreteOrder.orderUid, @@ -428,6 +432,10 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { isNull(candidateDiscreteOrder.possibleValidAfterTimestamp), lte(candidateDiscreteOrder.possibleValidAfterTimestamp, event.block.timestamp), ), + or( + isNull(candidateDiscreteOrder.validTo), + gt(candidateDiscreteOrder.validTo, Number(event.block.timestamp)), + ), ), ) as { orderUid: string; From 16678cc15fbd2b3d06a0f47ae4fee53e33dbb6c6 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 9 Jun 2026 19:26:25 -0300 Subject: [PATCH 23/84] feat: classify Curve/Balancer fee burners and CoW AMM as named order types (COW-1006) Identifies and classifies 5 of the top 9 Unknown handler addresses: - 0xc0fc3d (mainnet, 613 generators): Curve CowSwapBurner - 0x995831 (mainnet, 578): Balancer v3 CowSwapFeeBurner v2 - 0x0e800d (mainnet, 30): Balancer v3 CowSwapFeeBurner v1 (deprecated) - 0x254f3a (gnosis, 80): Balancer v3 CowSwapFeeBurner v2 - 0xb148f4 (gnosis, 110): CoW AMM ConstantProduct pool (verified) Remaining 4 addresses stay Unknown: two unverified gnosis proxies (likely CoW AMM but unconfirmed) and two unverified mainnet handlers from an unknown deployer. All new types are non-deterministic. They are added to NON_DETERMINISTIC_TYPES so C4 backfills them via /account/{owner}/orders at live-sync start. Co-Authored-By: Claude Sonnet 4.6 --- schema/tables.ts | 3 +++ src/application/handlers/blockHandler.ts | 2 +- src/utils/order-types.ts | 21 +++++++++++++++++++++ tests/utils/order-types.test.ts | 3 +++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/schema/tables.ts b/schema/tables.ts index ae58d1d..dc4501d 100644 --- a/schema/tables.ts +++ b/schema/tables.ts @@ -11,6 +11,9 @@ export const orderTypeEnum = onchainEnum("order_type", [ "CirclesBackingOrder", "SwapOrderHandler", "ERC4626CowSwapFeeBurner", + "CurveCowSwapBurner", + "BalancerCowSwapFeeBurner", + "CowAmmConstantProduct", "Unknown", ]); diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index dc5c822..1da89ed 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -46,7 +46,7 @@ import { log } from "../helpers/logger"; import { type OrderType } from "../../utils/order-types"; type DiscreteStatus = (typeof discreteOrderStatusEnum.enumValues)[number]; -const NON_DETERMINISTIC_TYPES: readonly OrderType[] = ["PerpetualSwap", "GoodAfterTime", "TradeAboveThreshold", "Unknown"]; +const NON_DETERMINISTIC_TYPES: readonly OrderType[] = ["PerpetualSwap", "GoodAfterTime", "TradeAboveThreshold", "CurveCowSwapBurner", "BalancerCowSwapFeeBurner", "CowAmmConstantProduct", "Unknown"]; const SINGLE_SHOT_NON_DETERMINISTIC: readonly OrderType[] = ["GoodAfterTime", "TradeAboveThreshold"]; const BLOCK_NEVER = 2n ** 63n - 1n; // sentinel for epoch-scheduled generators (PollTryAtEpoch) const VALID_DISCRETE_STATUSES = new Set(["fulfilled", "unfilled", "expired", "cancelled"]); diff --git a/src/utils/order-types.ts b/src/utils/order-types.ts index 93cd1b3..d40db60 100644 --- a/src/utils/order-types.ts +++ b/src/utils/order-types.ts @@ -21,6 +21,9 @@ export type OrderType = | "CirclesBackingOrder" | "SwapOrderHandler" | "ERC4626CowSwapFeeBurner" + | "CurveCowSwapBurner" + | "BalancerCowSwapFeeBurner" + | "CowAmmConstantProduct" | "Unknown"; /** @@ -43,12 +46,27 @@ export const HANDLER_ADDRESS_TO_TYPE: Record = { const MAINNET_ONLY_HANDLERS: Record = { "0xd506fe0b3ddf9e685c16e000514a835d3a511b26": "SwapOrderHandler", "0x816e90dc85bf016455017a76bc09cc0451eeb308": "ERC4626CowSwapFeeBurner", + // Curve Finance fee-burn handler: converts protocol fees → target token via CoW swap. + // Source: https://docs.curve.finance/fees/CowSwapBurner/ — Vyper 0.3.10, verified. + "0xc0fc3ddfec95ca45a0d2393f518d3ea1ccf44f8b": "CurveCowSwapBurner", + // Balancer v3 CowSwapFeeBurner: burns protocol fees via CoW swap. + // v2 (current): deployed via 20250530-v3-cow-swap-fee-burner-v2 task. + "0x9958317b80ee5f10457017d54c2484d722059157": "BalancerCowSwapFeeBurner", + // v1 (deprecated): deployed via 20250221-v3-cow-swap-fee-burner (now in deprecated/). + "0x0e800d8d2e8b4694610aedc385aa6d763492b106": "BalancerCowSwapFeeBurner", }; const GNOSIS_ONLY_HANDLERS: Record = { "0x43866c5602b0e3b3272424396e88b849796dc608": "CirclesBackingOrder", "0x7a77934d32d78bfe8dc1e23415b5679960a1c610": "SwapOrderHandler", "0x5915dea04ce390f0f44ca0806f7c6dd99ce2f941": "ERC4626CowSwapFeeBurner", + // Balancer v3 CowSwapFeeBurner v2 on Gnosis. + // Deployed via 20250530-v3-cow-swap-fee-burner-v2 task. + "0x254f3a2974b97dc2e675f6115c845567c55f83b0": "BalancerCowSwapFeeBurner", + // CoW AMM ConstantProduct pool (verified). Each pool instance IS its own handler — + // the pool address equals the handler address. Factory: ConstantProductFactory. + // Source: https://github.com/cowprotocol/cow-amm + "0xb148f40fff05b5ce6b22752cf8e454b556f7a851": "CowAmmConstantProduct", }; const HANDLER_MAP: Record> = { @@ -86,5 +104,8 @@ export const DETERMINISTIC_ORDER_TYPE: Record = { TradeAboveThreshold: false, SwapOrderHandler: false, ERC4626CowSwapFeeBurner: false, + CurveCowSwapBurner: false, + BalancerCowSwapFeeBurner: false, + CowAmmConstantProduct: false, Unknown: false, }; diff --git a/tests/utils/order-types.test.ts b/tests/utils/order-types.test.ts index 46e39d0..ce92226 100644 --- a/tests/utils/order-types.test.ts +++ b/tests/utils/order-types.test.ts @@ -25,6 +25,9 @@ describe("DETERMINISTIC_ORDER_TYPE", () => { expect(DETERMINISTIC_ORDER_TYPE["TradeAboveThreshold"]).toBe(false); expect(DETERMINISTIC_ORDER_TYPE["SwapOrderHandler"]).toBe(false); expect(DETERMINISTIC_ORDER_TYPE["ERC4626CowSwapFeeBurner"]).toBe(false); + expect(DETERMINISTIC_ORDER_TYPE["CurveCowSwapBurner"]).toBe(false); + expect(DETERMINISTIC_ORDER_TYPE["BalancerCowSwapFeeBurner"]).toBe(false); + expect(DETERMINISTIC_ORDER_TYPE["CowAmmConstantProduct"]).toBe(false); expect(DETERMINISTIC_ORDER_TYPE["Unknown"]).toBe(false); }); }); From 20457aefeccee78f6f0cf3f5b3202be4641d9f59 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Wed, 10 Jun 2026 18:12:20 -0300 Subject: [PATCH 24/84] perf: add composite indexes for C1/C5, C3, and C2 stale sweep (COW-886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generator_c1c5_poll_idx(chainId, status, allCandidatesKnown, lastCheckBlock): covers C1 and C5 per-block SELECTs which filter on all four columns and ORDER BY lastCheckBlock. Replaces the old partial generator_check_block_active_idx (nextCheckBlock, status) which did not cover chainId or allCandidatesKnown. - discrete_order_c3_status_idx(chainId, status, promotedAt): covers C3 per-block SELECT WHERE chainId + status='open' ORDER BY promotedAt. The previous statusIdx(status) had no chainId and no promotedAt — PostgreSQL would need a separate sort step on every block. - candidate_discrete_order_stale_idx(chainId, validTo): covers C2 stale sweep SELECT WHERE chainId + validTo <= timestamp LIMIT 500. Co-Authored-By: Claude Sonnet 4.6 --- schema/tables.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/schema/tables.ts b/schema/tables.ts index ae58d1d..2b6fb07 100644 --- a/schema/tables.ts +++ b/schema/tables.ts @@ -86,8 +86,11 @@ export const conditionalOrderGenerator = onchainTable( chainOwnerIdx: index().on(table.chainId, table.owner), resolvedOwnerIdx: index().on(table.resolvedOwner), ownerAddressTypeIdx: index().on(table.ownerAddressType), - checkBlockActiveIdx: index("generator_check_block_active_idx") - .on(table.nextCheckBlock, table.status), + // C1 (OrderDiscoveryPoller) + C5 (CancellationWatcher): per-block SELECT with + // chainId + status + allCandidatesKnown equality filters, ORDER BY lastCheckBlock. + // Covers both handlers — C1 queries allCandidatesKnown=false, C5 queries true. + c1c5PollIdx: index("generator_c1c5_poll_idx") + .on(table.chainId, table.status, table.allCandidatesKnown, table.lastCheckBlock), }) ); @@ -111,7 +114,9 @@ export const discreteOrder = onchainTable( pk: primaryKey({ columns: [table.chainId, table.orderUid] }), generatorIdx: index("discrete_order_generator_idx") .on(table.chainId, table.conditionalOrderGeneratorId), - statusIdx: index("discrete_order_status_idx").on(table.status), + // C3 (OrderStatusTracker): per-block SELECT with chainId + status='open', ORDER BY promotedAt. + c3StatusIdx: index("discrete_order_c3_status_idx") + .on(table.chainId, table.status, table.promotedAt), }) ); @@ -132,6 +137,9 @@ export const candidateDiscreteOrder = onchainTable( pk: primaryKey({ columns: [table.chainId, table.orderUid] }), generatorIdx: index("candidate_discrete_order_generator_idx") .on(table.chainId, table.conditionalOrderGeneratorId), + // C2 stale sweep: SELECT WHERE chainId + validTo <= timestamp LIMIT 500. + staleIdx: index("candidate_discrete_order_stale_idx") + .on(table.chainId, table.validTo), }) ); From 6811848a47361ebc856aecae4aa7c1f111a2263a Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Wed, 10 Jun 2026 18:19:46 -0300 Subject: [PATCH 25/84] fix: derive isComplete from isRealtime+pct instead of ponder_sync_is_complete (COW-1008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ponder_sync_is_complete never reaches 1 for chains with live block handlers (C1–C5 run indefinitely). isComplete is now computed locally as isRealtime && pct >= 100, which correctly reflects sync completion. Co-Authored-By: Claude Sonnet 4.6 --- src/api/endpoints/sync-progress.ts | 4 +++- tests/api/sync-progress.test.ts | 28 ++++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/api/endpoints/sync-progress.ts b/src/api/endpoints/sync-progress.ts index 4caf985..7678fdf 100644 --- a/src/api/endpoints/sync-progress.ts +++ b/src/api/endpoints/sync-progress.ts @@ -74,7 +74,9 @@ export const syncProgressHandler: RouteHandler = processedBlocks: processed, historicalBlocksFetchedPct: pct, isRealtime: (isRealtime.get(chain) ?? 0) === 1, - isComplete: (isComplete.get(chain) ?? 0) === 1, + // ponder_sync_is_complete never reaches 1 for chains with live block handlers + // (C1–C5 run indefinitely). Derive locally: synced means realtime + all blocks processed. + isComplete: (isRealtime.get(chain) ?? 0) === 1 && pct >= 100, }; } diff --git a/tests/api/sync-progress.test.ts b/tests/api/sync-progress.test.ts index b39e401..97e0727 100644 --- a/tests/api/sync-progress.test.ts +++ b/tests/api/sync-progress.test.ts @@ -95,14 +95,38 @@ describe("GET /api/sync-progress", () => { expect(body["gnosis"]!.historicalBlocksFetchedPct).toBe(14.1); }); - it("sets isRealtime and isComplete from metrics flags", async () => { + it("sets isRealtime from ponder_sync_is_realtime metric", async () => { mockFetch(SAMPLE_METRICS); const app = buildApp(); const res = await app.request("http://localhost/api/sync-progress"); const body = (await res.json()) as Record; expect(body["mainnet"]!.isRealtime).toBe(false); - expect(body["mainnet"]!.isComplete).toBe(false); expect(body["gnosis"]!.isRealtime).toBe(true); + }); + + it("isComplete requires both isRealtime=true and pct>=100 (ignores ponder_sync_is_complete)", async () => { + // gnosis is realtime but only 14.1% processed — must NOT be complete + mockFetch(SAMPLE_METRICS); + const app = buildApp(); + const res = await app.request("http://localhost/api/sync-progress"); + const body = (await res.json()) as Record; + expect(body["gnosis"]!.isRealtime).toBe(true); + expect(body["gnosis"]!.isComplete).toBe(false); + }); + + it("isComplete is true when isRealtime=true and all blocks processed", async () => { + // Regression: ponder_sync_is_complete=0 must not block isComplete when realtime+100% + const fullSyncMetrics = ` +ponder_historical_total_blocks{chain="gnosis"} 1000 +ponder_historical_completed_blocks{chain="gnosis"} 600 +ponder_historical_cached_blocks{chain="gnosis"} 400 +ponder_sync_is_realtime{chain="gnosis"} 1 +ponder_sync_is_complete{chain="gnosis"} 0 +`.trim(); + mockFetch(fullSyncMetrics); + const app = buildApp(); + const res = await app.request("http://localhost/api/sync-progress"); + const body = (await res.json()) as Record; expect(body["gnosis"]!.isComplete).toBe(true); }); From 0bcf1331bb1ede0aa2d5e486671b3af50e8e14fb Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Wed, 10 Jun 2026 18:21:15 -0300 Subject: [PATCH 26/84] refactor: use event.block.timestamp directly and type ComposableOrder.creationDate as bigint (COW-1007) blockHandler.ts used BigInt(Number(event.block.timestamp)) which unnecessarily round-trips through Number. orderbookClient's ComposableOrder declared creationDate as number, requiring a BigInt() cast at every DB insert site. Both are now consistent with the bigint column type and the rest of the codebase's timestamp handling. Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/blockHandler.ts | 2 +- src/application/helpers/orderbookClient.ts | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index dc5c822..0cf5ebe 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -192,7 +192,7 @@ ponder.on("OrderDiscoveryPoller:block", async ({ event, context }) => { buyAmount: orderData.buyAmount.toString(), feeAmount: orderData.feeAmount.toString(), validTo: orderData.validTo, - creationDate: BigInt(Number(event.block.timestamp)), + creationDate: event.block.timestamp, }) .onConflictDoNothing(), ); diff --git a/src/application/helpers/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index 0b99df5..f6ea119 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -46,8 +46,7 @@ interface OrderbookOrder { } /** Processed composable order stored in cache and returned to callers. - * Shares field types with the discreteOrder schema for the DB-mapped fields. - * creationDate is number here (unix seconds) and converted to bigint at insert time. */ + * Shares field types with the discreteOrder schema for the DB-mapped fields. */ export type ComposableOrder = Pick< typeof discreteOrder.$inferInsert, "status" | "sellAmount" | "buyAmount" | "feeAmount" | "validTo" | "executedSellAmount" | "executedBuyAmount" @@ -56,7 +55,7 @@ export type ComposableOrder = Pick< generatorId: string; generatorHash: string; orderType: OrderType; - creationDate: number; + creationDate: bigint; }; /** Status + executed amounts returned by fetchOrderStatusByUids. */ @@ -187,7 +186,7 @@ export async function upsertDiscreteOrders( buyAmount: order.buyAmount, feeAmount: order.feeAmount, validTo: order.validTo, - creationDate: BigInt(order.creationDate), + creationDate: order.creationDate, executedSellAmount: order.executedSellAmount, executedBuyAmount: order.executedBuyAmount, }) @@ -278,7 +277,7 @@ export async function fetchOrderStatusByUids( buyAmount: order.buyAmount, feeAmount: order.feeAmount, validTo: order.validTo, - creationDate: 0, + creationDate: 0n, executedSellAmount: order.executedSellAmount, executedBuyAmount: order.executedBuyAmount, }); @@ -473,7 +472,7 @@ async function filterAndProcess( buyAmount: order.buyAmount, feeAmount: order.feeAmount, validTo: order.validTo, - creationDate: Math.floor(new Date(order.creationDate).getTime() / 1000), + creationDate: BigInt(Math.floor(new Date(order.creationDate).getTime() / 1000)), executedSellAmount: order.executedSellAmount, executedBuyAmount: order.executedBuyAmount, }); From cefb22a87f111210661b7246752f68eaa8df6c89 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Wed, 10 Jun 2026 18:27:08 -0300 Subject: [PATCH 27/84] fix: add missing ORDERBOOK_HTTP_TIMEOUT_MS import; sync hash field description in test (final-qa merge cleanup) Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/blockHandler.ts | 1 + tests/api/orders-by-owner.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 5dc20f5..33b7c02 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -28,6 +28,7 @@ import { DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK, 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, diff --git a/tests/api/orders-by-owner.test.ts b/tests/api/orders-by-owner.test.ts index eaa3614..57a5ce1 100644 --- a/tests/api/orders-by-owner.test.ts +++ b/tests/api/orders-by-owner.test.ts @@ -214,7 +214,7 @@ describe("GeneratorSummary schema", () => { const shape = GeneratorSummary.shape; const description = shape.hash.description; expect(description).toBe( - "On-chain canonical identifier: keccak256(abi.encode((handler, salt, staticInput))). Used by ComposableCow.singleOrders(owner, hash) and remove(owner, hash).", + "On-chain canonical identifier: keccak256(abi.encode(ConditionalOrderParams { handler, salt, staticInput })) — the value returned by ComposableCow.hash(params) and used as the key in singleOrders(owner, hash) and remove(owner, hash).", ); }); From 839bc30fdfcd43f2f78256b9490eee05978dd8f6 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 15 Jun 2026 04:46:44 -0300 Subject: [PATCH 28/84] fix: replace context.db.sql writes with ORM in SettlementResolver block handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit context.db.sql write operations call indexingCache.flush() internally, which creates SAVEPOINTs inside the outer block transaction. Under multichain realtime conditions indexingCache.qb can be the raw connection pool instead of the active transaction, causing "SAVEPOINT can only be used in transaction blocks" and crashing the indexer on every block tick. Replace both context.db.sql.delete(settlementQueue) calls with context.db.delete (safe ORM path, writes to in-memory cache, no flush). Remove the context.db.sql.update(conditionalOrderGenerator) call entirely — ownerMapping already records FlashLoanHelper addresses; the denormalised field on conditionalOrderGenerator can be re-added via a safe ORM update by PK in a follow-up task. Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/settlement.ts | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/src/application/handlers/settlement.ts b/src/application/handlers/settlement.ts index 3aad857..7d4f629 100644 --- a/src/application/handlers/settlement.ts +++ b/src/application/handlers/settlement.ts @@ -1,7 +1,6 @@ import { ponder } from "ponder:registry"; import { AddressType, - conditionalOrderGenerator, ownerMapping, settlementQueue, transaction, @@ -114,9 +113,7 @@ ponder.on("SettlementResolver:block", async ({ event: _event, context }) => { ); } catch (err) { log("warn", "SettlementResolver:receipt_failed", { chainId, txHash: item.txHash, err: err instanceof Error ? err.message : String(err) }); - await context.db.sql - .delete(settlementQueue) - .where(and(eq(settlementQueue.chainId, chainId), eq(settlementQueue.txHash, item.txHash))); + await context.db.delete(settlementQueue, { chainId, txHash: item.txHash }); continue; } @@ -227,25 +224,13 @@ ponder.on("SettlementResolver:block", async ({ event: _event, context }) => { }) .onConflictDoNothing(); - await context.db.sql - .update(conditionalOrderGenerator) - .set({ ownerAddressType: AddressType.FlashLoanHelper }) - .where( - and( - eq(conditionalOrderGenerator.chainId, chainId), - eq(conditionalOrderGenerator.owner, ownerAddress), - ), - ); - stats.mapped++; logStatsIfIntervalPassed(); log("info", "SettlementResolver:aave_adapter_mapped", { chainId, adapter: ownerAddress, eoa: eoaOwner.toLowerCase(), block: String(item.blockNumber) }); } - await context.db.sql - .delete(settlementQueue) - .where(and(eq(settlementQueue.chainId, chainId), eq(settlementQueue.txHash, item.txHash))); + await context.db.delete(settlementQueue, { chainId, txHash: item.txHash }); logStatsIfIntervalPassed(); } From 08f2cfef94f3c1ed8a440cc946aa272a15c1f00a Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 15 Jun 2026 09:57:51 -0300 Subject: [PATCH 29/84] chore: upgrade ponder from 0.16.3 to 0.16.6 Co-Authored-By: Claude Sonnet 4.6 --- package.json | 4 ++-- pnpm-lock.yaml | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index da50219..e54fb1a 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,11 @@ }, "dependencies": { "@cowprotocol/cow-sdk": "^7.3.8", - "drizzle-orm": "0.41.0", "@hono/swagger-ui": "^0.5.3", "@hono/zod-openapi": "^0.19.10", + "drizzle-orm": "0.41.0", "hono": "^4.5.0", - "ponder": "^0.16.2", + "ponder": "^0.16.6", "ponder-enrich-gql-docs-middleware": "^0.1.3", "viem": "^2.21.3", "zod": "^3.25.76" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4eb45f..c91bf08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^4.5.0 version: 4.12.3 ponder: - specifier: ^0.16.2 - version: 0.16.3(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) + specifier: ^0.16.6 + version: 0.16.6(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) ponder-enrich-gql-docs-middleware: specifier: ^0.1.3 version: 0.1.3(graphql@16.8.2)(hono@4.12.3) @@ -797,8 +797,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@ponder/utils@0.2.17': - resolution: {integrity: sha512-ins3XXzm+2jMGa0aTc1N10JG0Sau9Vvk71llOatkEOkncMne9m/mMYxJsV6WfRaZGlo4FB8OoRM6DVPHZlRGHA==} + '@ponder/utils@0.2.18': + resolution: {integrity: sha512-pWacQuohfKlKO440zC39C1l6AH+Vx18mca0cPkxM0YnrB70px5hEwLKrc3fU4DMu72j4pljy/Mvfm95FBouYjw==} peerDependencies: typescript: '>=5.0.4' viem: '>=2' @@ -2226,8 +2226,8 @@ packages: graphql: ^16.10.0 hono: ^4.6.19 - ponder@0.16.3: - resolution: {integrity: sha512-QwAg5eFUujWR87ZMeTTKmF17q9IVTew26tpdaTfQrrtJVf/oU8ZN1qu7ZcP2SPtK5oScblJMWQm9zfwst6tJcg==} + ponder@0.16.6: + resolution: {integrity: sha512-y4JusgnLZ9uACTr9pj0DjfPSqlsaSLJ7Gxpe/ByfwR5fHuwoPOoolix+Y+9EPqs0cr1KxrG35aVBCR0kT+LJew==} engines: {node: '>=18.14'} hasBin: true peerDependencies: @@ -3436,7 +3436,7 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@ponder/utils@0.2.17(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))': + '@ponder/utils@0.2.18(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))': dependencies: viem: 2.46.3(typescript@5.9.3)(zod@3.25.76) optionalDependencies: @@ -4844,7 +4844,7 @@ snapshots: graphql: 16.8.2 hono: 4.12.3 - ponder@0.16.3(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76): + ponder@0.16.6(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76): dependencies: '@babel/code-frame': 7.29.0 '@commander-js/extra-typings': 12.1.0(commander@12.1.0) @@ -4853,7 +4853,7 @@ snapshots: '@escape.tech/graphql-armor-max-depth': 2.4.2 '@escape.tech/graphql-armor-max-tokens': 2.5.1 '@hono/node-server': 1.19.5(hono@4.12.3) - '@ponder/utils': 0.2.17(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76)) + '@ponder/utils': 0.2.18(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76)) abitype: 0.10.3(typescript@5.9.3)(zod@3.25.76) ansi-escapes: 7.3.0 commander: 12.1.0 From 4d4a8557503b17f0fe7a63c23602cfe8816c936b Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 15 Jun 2026 10:06:02 -0300 Subject: [PATCH 30/84] fix: patch ponder flush() to use unique savepoint names per invocation Concurrent context.db.sql calls (e.g. Promise.all in OrderDiscoveryPoller) each trigger indexingCache.flush(), which issues SAVEPOINT flush / RELEASE flush on the same PostgreSQL transaction. Because all calls use the hardcoded name "flush", concurrent invocations corrupt each other: the second SAVEPOINT flush replaces the first, so RELEASE flush from the first call releases the wrong savepoint and the second gets "savepoint flush does not exist". Adds a per-instance counter (_flushId) and generates flush_N as the savepoint name for each flush() call. All SAVEPOINT, RELEASE, and ROLLBACK TO references within one flush invocation use the same unique name, preserving the existing retry semantics while eliminating name collisions across concurrent calls. Co-Authored-By: Claude Sonnet 4.6 --- patches/ponder@0.16.6.patch | 73 +++++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 2 + 2 files changed, 75 insertions(+) create mode 100644 patches/ponder@0.16.6.patch create mode 100644 pnpm-workspace.yaml diff --git a/patches/ponder@0.16.6.patch b/patches/ponder@0.16.6.patch new file mode 100644 index 0000000..f075726 --- /dev/null +++ b/patches/ponder@0.16.6.patch @@ -0,0 +1,73 @@ +diff --git a/dist/esm/indexing-store/cache.js b/dist/esm/indexing-store/cache.js +index e2f6a03650b5b80cae9ec4dc0a8837cb054cae18..333eec6cd7951ede6121474576f9367f5edb0262 100644 +--- a/dist/esm/indexing-store/cache.js ++++ b/dist/esm/indexing-store/cache.js +@@ -157,6 +157,7 @@ export const recoverBatchError = async (values, callback) => { + export const createIndexingCache = ({ common, schemaBuild: { schema }, crashRecoveryCheckpoint, eventCount, chainId, }) => { + let event; + let qb = undefined; ++ let _flushId = 0; + const cache = new Map(); + const insertBuffer = new Map(); + const updateBuffer = new Map(); +@@ -283,6 +284,7 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco + return inInsertBuffer || inUpdateBuffer || inDb; + }, + async flush({ tableNames } = {}) { ++ const sp = `flush_${++_flushId}`; + const context = { + logger: common.logger.child({ action: "flush_database_rows" }), + }; +@@ -392,9 +394,9 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco + }; + try { + if (qb.$dialect === "postgres") { +- await qb.wrap((db) => db.execute("SAVEPOINT flush"), context); ++ await qb.wrap((db) => db.execute(`SAVEPOINT ${sp}`), context); + await promiseAllSettledWithThrow(Array.from(cache.keys()).map(flushTable)); +- await qb.wrap((db) => db.execute("RELEASE flush"), context); ++ await qb.wrap((db) => db.execute(`RELEASE ${sp}`), context); + } + else { + // Note: pglite must run sequentially +@@ -411,7 +413,7 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco + // Note `isFlushRetry` is true when the previous flush failed. When `isFlushRetry` is false, this + // function takes an optimized fast path, with support for small batch sizes. PGlite always takes + // the fast path because it doesn't support delayed insert errors. +- await qb.wrap((db) => db.execute("ROLLBACK to flush"), context); ++ await qb.wrap((db) => db.execute(`ROLLBACK to ${sp}`), context); + for (const table of cache.keys()) { + if (tableNames !== undefined && + tableNames.has(getTableName(table)) === false) { +@@ -424,12 +426,12 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco + const updateValues = Array.from(updateBuffer.get(table).values()); + if (insertValues.length > 0) { + const endClock = startClock(); +- await qb.wrap((db) => db.execute("SAVEPOINT flush"), context); ++ await qb.wrap((db) => db.execute(`SAVEPOINT ${sp}`), context); + const result = await recoverBatchError(insertValues, async (values) => { +- await qb.wrap((db) => db.execute("ROLLBACK to flush"), context); ++ await qb.wrap((db) => db.execute(`ROLLBACK to ${sp}`), context); + const text = getCopyText(table, values.map(({ row }) => row)); + await copy(table, text); +- await qb.wrap((db) => db.execute("SAVEPOINT flush"), context); ++ await qb.wrap((db) => db.execute(`SAVEPOINT ${sp}`), context); + }); + if (result.status === "error") { + error = new DelayedInsertError(result.error.message); +@@ -472,12 +474,12 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco + `; + const endClock = startClock(); + await qb.wrap((db) => db.execute(createTempTableQuery), context); +- await qb.wrap((db) => db.execute("SAVEPOINT flush"), context); ++ await qb.wrap((db) => db.execute(`SAVEPOINT ${sp}`), context); + const result = await recoverBatchError(updateValues, async (values) => { +- await qb.wrap((db) => db.execute("ROLLBACK to flush"), context); ++ await qb.wrap((db) => db.execute(`ROLLBACK to ${sp}`), context); + const text = getCopyText(table, values.map(({ row }) => row)); + await copy(table, text, false); +- await qb.wrap((db) => db.execute("SAVEPOINT flush"), context); ++ await qb.wrap((db) => db.execute(`SAVEPOINT ${sp}`), context); + }); + await qb.wrap((db) => db.execute(`TRUNCATE TABLE "${target}"`), context); + if (result.status === "error") { diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..a3ea2e9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +patchedDependencies: + ponder@0.16.6: patches/ponder@0.16.6.patch From eaaef905c674339a75e55ff8ac16986f82ce4560 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 15 Jun 2026 10:07:59 -0300 Subject: [PATCH 31/84] chore: add pnpm-workspace.yaml and patches/ to Dockerfile build stage Required for pnpm patched dependencies to be applied during Docker build. Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7fd307b..4dffd06 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,8 @@ WORKDIR /usr/src/app # ---- build stage ---- FROM base AS build -COPY package.json pnpm-lock.yaml ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY patches/ patches/ RUN pnpm install --frozen-lockfile COPY . . From 8effd7883b2624c6dc92346e5a060152050a2cf4 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 15 Jun 2026 10:07:59 -0300 Subject: [PATCH 32/84] chore: add pnpm-workspace.yaml and patches/ to Dockerfile build stage Required for pnpm patched dependencies to be applied during Docker build. Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7fd307b..4dffd06 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,8 @@ WORKDIR /usr/src/app # ---- build stage ---- FROM base AS build -COPY package.json pnpm-lock.yaml ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY patches/ patches/ RUN pnpm install --frozen-lockfile COPY . . From 3279e91f9a3327ca9535f1642036b120ea91e858 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 15 Jun 2026 10:10:02 -0300 Subject: [PATCH 33/84] chore: update pnpm-lock.yaml with ponder patch hash Co-Authored-By: Claude Sonnet 4.6 --- pnpm-lock.yaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c91bf08..cee4903 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + ponder@0.16.6: + hash: f8e8c82e422f563e9c2f3ac2442cc7c69ddd415c4dc1071b411db5ccaa50bd1f + path: patches/ponder@0.16.6.patch + importers: .: @@ -25,7 +30,7 @@ importers: version: 4.12.3 ponder: specifier: ^0.16.6 - version: 0.16.6(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) + version: 0.16.6(patch_hash=f8e8c82e422f563e9c2f3ac2442cc7c69ddd415c4dc1071b411db5ccaa50bd1f)(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) ponder-enrich-gql-docs-middleware: specifier: ^0.1.3 version: 0.1.3(graphql@16.8.2)(hono@4.12.3) @@ -4844,7 +4849,7 @@ snapshots: graphql: 16.8.2 hono: 4.12.3 - ponder@0.16.6(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76): + ponder@0.16.6(patch_hash=f8e8c82e422f563e9c2f3ac2442cc7c69ddd415c4dc1071b411db5ccaa50bd1f)(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76): dependencies: '@babel/code-frame': 7.29.0 '@commander-js/extra-typings': 12.1.0(commander@12.1.0) From 86b9d15dad74602bf05ed076fffdd2c7fdd2ea55 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 15 Jun 2026 10:10:02 -0300 Subject: [PATCH 34/84] chore: update pnpm-lock.yaml with ponder patch hash Co-Authored-By: Claude Sonnet 4.6 --- pnpm-lock.yaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c91bf08..cee4903 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + ponder@0.16.6: + hash: f8e8c82e422f563e9c2f3ac2442cc7c69ddd415c4dc1071b411db5ccaa50bd1f + path: patches/ponder@0.16.6.patch + importers: .: @@ -25,7 +30,7 @@ importers: version: 4.12.3 ponder: specifier: ^0.16.6 - version: 0.16.6(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) + version: 0.16.6(patch_hash=f8e8c82e422f563e9c2f3ac2442cc7c69ddd415c4dc1071b411db5ccaa50bd1f)(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) ponder-enrich-gql-docs-middleware: specifier: ^0.1.3 version: 0.1.3(graphql@16.8.2)(hono@4.12.3) @@ -4844,7 +4849,7 @@ snapshots: graphql: 16.8.2 hono: 4.12.3 - ponder@0.16.6(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76): + ponder@0.16.6(patch_hash=f8e8c82e422f563e9c2f3ac2442cc7c69ddd415c4dc1071b411db5ccaa50bd1f)(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76): dependencies: '@babel/code-frame': 7.29.0 '@commander-js/extra-typings': 12.1.0(commander@12.1.0) From 8b02433a35c481087bbe85e5d3a63d250060b4ff Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 15 Jun 2026 14:07:23 -0300 Subject: [PATCH 35/84] fix: snapshot qb at flush() start to prevent mid-flush external mutation indexingCache.qb is mutated by multichain.ts between SAVEPOINT and RELEASE within the same flush() invocation. Captures qb as currentQb at flush() start so all SAVEPOINT/RELEASE/ROLLBACK TO and qb.wrap calls use a stable connection reference regardless of external mutations during async flushTable operations. Co-Authored-By: Claude Sonnet 4.6 --- patches/ponder@0.16.6.patch | 86 +++++++++++++++++++++++++++++-------- pnpm-lock.yaml | 6 +-- 2 files changed, 71 insertions(+), 21 deletions(-) diff --git a/patches/ponder@0.16.6.patch b/patches/ponder@0.16.6.patch index f075726..a20b362 100644 --- a/patches/ponder@0.16.6.patch +++ b/patches/ponder@0.16.6.patch @@ -1,5 +1,5 @@ diff --git a/dist/esm/indexing-store/cache.js b/dist/esm/indexing-store/cache.js -index e2f6a03650b5b80cae9ec4dc0a8837cb054cae18..333eec6cd7951ede6121474576f9367f5edb0262 100644 +index e2f6a03650b5b80cae9ec4dc0a8837cb054cae18..fd3307719ef8b297b87f64e6a7736ae7d812757e 100644 --- a/dist/esm/indexing-store/cache.js +++ b/dist/esm/indexing-store/cache.js @@ -157,6 +157,7 @@ export const recoverBatchError = async (values, callback) => { @@ -10,64 +10,114 @@ index e2f6a03650b5b80cae9ec4dc0a8837cb054cae18..333eec6cd7951ede6121474576f9367f const cache = new Map(); const insertBuffer = new Map(); const updateBuffer = new Map(); -@@ -283,6 +284,7 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco +@@ -283,11 +284,13 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco return inInsertBuffer || inUpdateBuffer || inDb; }, async flush({ tableNames } = {}) { + const sp = `flush_${++_flushId}`; ++ const currentQb = qb; const context = { logger: common.logger.child({ action: "flush_database_rows" }), }; -@@ -392,9 +394,9 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco + const flushEndClock = startClock(); +- const copy = getCopyHelper(qb, chainId); ++ const copy = getCopyHelper(currentQb, chainId); + const flushTable = async (table) => { + const shouldRecordBytes = cache.get(table).isCacheComplete; + if (tableNames !== undefined && +@@ -308,7 +311,7 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco + await copy(table, text); + } + else { +- await qb.wrap((db) => db.insert(table).values(insertValues.map(({ row }) => row)), context); ++ await currentQb.wrap((db) => db.insert(table).values(insertValues.map(({ row }) => row)), context); + } + common.metrics.ponder_indexing_cache_query_duration.observe({ + table: getTableName(table), +@@ -346,15 +349,15 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco + WHERE ${primaryKeys + .map(({ sql }) => `target."${sql}" = source."${sql}"`) + .join(" AND ")};`; +- await qb.wrap((db) => db.execute(createTempTableQuery), context); ++ await currentQb.wrap((db) => db.execute(createTempTableQuery), context); + const text = getCopyText(table, updateValues.map(({ row }) => row)); + await new Promise(setImmediate); + await copy(table, text, false); +- await qb.wrap((db) => db.execute(updateQuery), context); +- await qb.wrap((db) => db.execute(`TRUNCATE TABLE "${target}"`), context); ++ await currentQb.wrap((db) => db.execute(updateQuery), context); ++ await currentQb.wrap((db) => db.execute(`TRUNCATE TABLE "${target}"`), context); + } + else { +- await qb.wrap((db) => db ++ await currentQb.wrap((db) => db + .insert(table) + .values(updateValues.map(({ row }) => row)) + .onConflictDoUpdate({ +@@ -391,10 +394,10 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco + } }; try { - if (qb.$dialect === "postgres") { +- if (qb.$dialect === "postgres") { - await qb.wrap((db) => db.execute("SAVEPOINT flush"), context); -+ await qb.wrap((db) => db.execute(`SAVEPOINT ${sp}`), context); ++ if (currentQb.$dialect === "postgres") { ++ await currentQb.wrap((db) => db.execute(`SAVEPOINT ${sp}`), context); await promiseAllSettledWithThrow(Array.from(cache.keys()).map(flushTable)); - await qb.wrap((db) => db.execute("RELEASE flush"), context); -+ await qb.wrap((db) => db.execute(`RELEASE ${sp}`), context); ++ await currentQb.wrap((db) => db.execute(`RELEASE ${sp}`), context); } else { // Note: pglite must run sequentially -@@ -411,7 +413,7 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco +@@ -405,13 +408,13 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco + } + catch (_error) { + let error = _error; +- if (error instanceof ShutdownError || qb.$dialect === "pglite") { ++ if (error instanceof ShutdownError || currentQb.$dialect === "pglite") { + throw error; + } // Note `isFlushRetry` is true when the previous flush failed. When `isFlushRetry` is false, this // function takes an optimized fast path, with support for small batch sizes. PGlite always takes // the fast path because it doesn't support delayed insert errors. - await qb.wrap((db) => db.execute("ROLLBACK to flush"), context); -+ await qb.wrap((db) => db.execute(`ROLLBACK to ${sp}`), context); ++ await currentQb.wrap((db) => db.execute(`ROLLBACK to ${sp}`), context); for (const table of cache.keys()) { if (tableNames !== undefined && tableNames.has(getTableName(table)) === false) { -@@ -424,12 +426,12 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco +@@ -424,12 +427,12 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco const updateValues = Array.from(updateBuffer.get(table).values()); if (insertValues.length > 0) { const endClock = startClock(); - await qb.wrap((db) => db.execute("SAVEPOINT flush"), context); -+ await qb.wrap((db) => db.execute(`SAVEPOINT ${sp}`), context); ++ await currentQb.wrap((db) => db.execute(`SAVEPOINT ${sp}`), context); const result = await recoverBatchError(insertValues, async (values) => { - await qb.wrap((db) => db.execute("ROLLBACK to flush"), context); -+ await qb.wrap((db) => db.execute(`ROLLBACK to ${sp}`), context); ++ await currentQb.wrap((db) => db.execute(`ROLLBACK to ${sp}`), context); const text = getCopyText(table, values.map(({ row }) => row)); await copy(table, text); - await qb.wrap((db) => db.execute("SAVEPOINT flush"), context); -+ await qb.wrap((db) => db.execute(`SAVEPOINT ${sp}`), context); ++ await currentQb.wrap((db) => db.execute(`SAVEPOINT ${sp}`), context); }); if (result.status === "error") { error = new DelayedInsertError(result.error.message); -@@ -472,12 +474,12 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco +@@ -471,15 +474,15 @@ export const createIndexingCache = ({ common, schemaBuild: { schema }, crashReco + WITH NO DATA; `; const endClock = startClock(); - await qb.wrap((db) => db.execute(createTempTableQuery), context); +- await qb.wrap((db) => db.execute(createTempTableQuery), context); - await qb.wrap((db) => db.execute("SAVEPOINT flush"), context); -+ await qb.wrap((db) => db.execute(`SAVEPOINT ${sp}`), context); ++ await currentQb.wrap((db) => db.execute(createTempTableQuery), context); ++ await currentQb.wrap((db) => db.execute(`SAVEPOINT ${sp}`), context); const result = await recoverBatchError(updateValues, async (values) => { - await qb.wrap((db) => db.execute("ROLLBACK to flush"), context); -+ await qb.wrap((db) => db.execute(`ROLLBACK to ${sp}`), context); ++ await currentQb.wrap((db) => db.execute(`ROLLBACK to ${sp}`), context); const text = getCopyText(table, values.map(({ row }) => row)); await copy(table, text, false); - await qb.wrap((db) => db.execute("SAVEPOINT flush"), context); -+ await qb.wrap((db) => db.execute(`SAVEPOINT ${sp}`), context); ++ await currentQb.wrap((db) => db.execute(`SAVEPOINT ${sp}`), context); }); - await qb.wrap((db) => db.execute(`TRUNCATE TABLE "${target}"`), context); +- await qb.wrap((db) => db.execute(`TRUNCATE TABLE "${target}"`), context); ++ await currentQb.wrap((db) => db.execute(`TRUNCATE TABLE "${target}"`), context); if (result.status === "error") { + error = new DelayedInsertError(result.error.message); + error.stack = undefined; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cee4903..7e6ee61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: ponder@0.16.6: - hash: f8e8c82e422f563e9c2f3ac2442cc7c69ddd415c4dc1071b411db5ccaa50bd1f + hash: b651e31c90c763131f59eab16aebe031f6a4521c0de021d24cd298e0865b1252 path: patches/ponder@0.16.6.patch importers: @@ -30,7 +30,7 @@ importers: version: 4.12.3 ponder: specifier: ^0.16.6 - version: 0.16.6(patch_hash=f8e8c82e422f563e9c2f3ac2442cc7c69ddd415c4dc1071b411db5ccaa50bd1f)(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) + version: 0.16.6(patch_hash=b651e31c90c763131f59eab16aebe031f6a4521c0de021d24cd298e0865b1252)(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) ponder-enrich-gql-docs-middleware: specifier: ^0.1.3 version: 0.1.3(graphql@16.8.2)(hono@4.12.3) @@ -4849,7 +4849,7 @@ snapshots: graphql: 16.8.2 hono: 4.12.3 - ponder@0.16.6(patch_hash=f8e8c82e422f563e9c2f3ac2442cc7c69ddd415c4dc1071b411db5ccaa50bd1f)(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76): + ponder@0.16.6(patch_hash=b651e31c90c763131f59eab16aebe031f6a4521c0de021d24cd298e0865b1252)(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76): dependencies: '@babel/code-frame': 7.29.0 '@commander-js/extra-typings': 12.1.0(commander@12.1.0) From 07b1ab89e629de354bb4ccc64c00c706687b1566 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 15 Jun 2026 14:07:43 -0300 Subject: [PATCH 36/84] fix: resolve merge conflict - use complete ponder patch with currentQb snapshot --- patches/ponder@0.16.6.patch | 2 +- pnpm-lock.yaml | 6 +++--- src/chains/gnosis.ts | 6 +++--- src/chains/index.ts | 2 +- src/chains/mainnet.ts | 6 +++--- src/constants.ts | 1 - 6 files changed, 11 insertions(+), 12 deletions(-) diff --git a/patches/ponder@0.16.6.patch b/patches/ponder@0.16.6.patch index a20b362..f14387b 100644 --- a/patches/ponder@0.16.6.patch +++ b/patches/ponder@0.16.6.patch @@ -1,5 +1,5 @@ diff --git a/dist/esm/indexing-store/cache.js b/dist/esm/indexing-store/cache.js -index e2f6a03650b5b80cae9ec4dc0a8837cb054cae18..fd3307719ef8b297b87f64e6a7736ae7d812757e 100644 +index e2f6a03650b5b80cae9ec4dc0a8837cb054cae18..b57f4f4b32e03b221b3985f9b9218b239df9c7b2 100644 --- a/dist/esm/indexing-store/cache.js +++ b/dist/esm/indexing-store/cache.js @@ -157,6 +157,7 @@ export const recoverBatchError = async (values, callback) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e6ee61..f1fcb04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: ponder@0.16.6: - hash: b651e31c90c763131f59eab16aebe031f6a4521c0de021d24cd298e0865b1252 + hash: ec9f89214374efb4dd58ba2291160b5265874a5585c4a5c4c2e778a766371e0a path: patches/ponder@0.16.6.patch importers: @@ -30,7 +30,7 @@ importers: version: 4.12.3 ponder: specifier: ^0.16.6 - version: 0.16.6(patch_hash=b651e31c90c763131f59eab16aebe031f6a4521c0de021d24cd298e0865b1252)(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) + version: 0.16.6(patch_hash=ec9f89214374efb4dd58ba2291160b5265874a5585c4a5c4c2e778a766371e0a)(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76) ponder-enrich-gql-docs-middleware: specifier: ^0.1.3 version: 0.1.3(graphql@16.8.2)(hono@4.12.3) @@ -4849,7 +4849,7 @@ snapshots: graphql: 16.8.2 hono: 4.12.3 - ponder@0.16.6(patch_hash=b651e31c90c763131f59eab16aebe031f6a4521c0de021d24cd298e0865b1252)(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76): + ponder@0.16.6(patch_hash=ec9f89214374efb4dd58ba2291160b5265874a5585c4a5c4c2e778a766371e0a)(@opentelemetry/api@1.9.0)(@types/node@20.19.35)(hono@4.12.3)(typescript@5.9.3)(viem@2.46.3(typescript@5.9.3)(zod@3.25.76))(zod@3.25.76): dependencies: '@babel/code-frame': 7.29.0 '@commander-js/extra-typings': 12.1.0(commander@12.1.0) diff --git a/src/chains/gnosis.ts b/src/chains/gnosis.ts index cfbdc18..a9fe604 100644 --- a/src/chains/gnosis.ts +++ b/src/chains/gnosis.ts @@ -10,7 +10,7 @@ export const gnosis: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", - startBlock: 29389123, + startBlock: 46702200, }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", @@ -20,11 +20,11 @@ export const gnosis: ChainConfig = { "0x4f4350bf2c74aacd508d598a1ba94ef84378793d", // current (CoWShedForComposableCoW) "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // legacy (COWShed); 2 historical events ] as const, - startBlock: 41469991, // earliest COWShedBuilt from either factory on Gnosis + startBlock: 46702200, }, gpv2Settlement: { address: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", - startBlock: 43177077, // AaveV3AdapterFactory deployment block on Gnosis + startBlock: 46702200, }, flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", // confirmed via ROUTER() on Gnosis AaveV3AdapterFactory aaveV3AdapterFactory: "0xdeCc46a4b09162f5369c5c80383aaa9159bcf192", // verified on Gnosisscan diff --git a/src/chains/index.ts b/src/chains/index.ts index fcc50b1..b3e2568 100644 --- a/src/chains/index.ts +++ b/src/chains/index.ts @@ -44,7 +44,7 @@ export const ALL_DEFINED_CHAINS = [ * ponder.config.ts derives all RPC/contract config from this array. */ export const ACTIVE_CHAINS = [ - mainnet, + // mainnet, gnosis, // arbitrum, // fully verified — enable when ARBITRUM_RPC_URL is provisioned // base, // fully verified — enable when BASE_RPC_URL is provisioned diff --git a/src/chains/mainnet.ts b/src/chains/mainnet.ts index 58a5b25..7fdebb2 100644 --- a/src/chains/mainnet.ts +++ b/src/chains/mainnet.ts @@ -10,18 +10,18 @@ export const mainnet: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", - startBlock: 17883049, + startBlock: 25319424, }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", }, cowShedFactory: { address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", - startBlock: 22939254, + startBlock: 25319424, }, gpv2Settlement: { address: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", - startBlock: 23812751, // AaveV3AdapterFactory deployment block (Nov 16, 2025) + startBlock: 23928242, }, flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", aaveV3AdapterFactory: "0xdeCc46a4b09162f5369c5c80383aaa9159bcf192", diff --git a/src/constants.ts b/src/constants.ts index ce3972d..7e39bee 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -75,7 +75,6 @@ export const BLOCK_HANDLER_RPC_TIMEOUT_MS = 15_000; // Tighter cap for cheap inner-loop calls (getCode, eth_call) in the settlement handler. // The outer receipt fetch and readContract(owner()) keep the full 15 s. export const SETTLEMENT_INNER_RPC_TIMEOUT_MS = 5_000; - /** * Hard wall-clock cap for the whole per-owner bootstrap fetch in OwnerBackfill * (account pagination + by_uids refresh). Owners that exceed this are skipped; From 209e167a5c1df4fb7af1ebe25094387a89f045ce Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 15 Jun 2026 15:05:30 -0300 Subject: [PATCH 37/84] fix: restore chain configs to production values (revert accidental test changes) mainnet was accidentally disabled and gnosis/mainnet startBlocks were set to test values during local reproduction attempts. Restoring to the original production config from cefb22a. Co-Authored-By: Claude Sonnet 4.6 --- src/chains/gnosis.ts | 6 +++--- src/chains/index.ts | 2 +- src/chains/mainnet.ts | 6 +++--- src/constants.ts | 1 + 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/chains/gnosis.ts b/src/chains/gnosis.ts index a9fe604..cfbdc18 100644 --- a/src/chains/gnosis.ts +++ b/src/chains/gnosis.ts @@ -10,7 +10,7 @@ export const gnosis: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", - startBlock: 46702200, + startBlock: 29389123, }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", @@ -20,11 +20,11 @@ export const gnosis: ChainConfig = { "0x4f4350bf2c74aacd508d598a1ba94ef84378793d", // current (CoWShedForComposableCoW) "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // legacy (COWShed); 2 historical events ] as const, - startBlock: 46702200, + startBlock: 41469991, // earliest COWShedBuilt from either factory on Gnosis }, gpv2Settlement: { address: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", - startBlock: 46702200, + startBlock: 43177077, // AaveV3AdapterFactory deployment block on Gnosis }, flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", // confirmed via ROUTER() on Gnosis AaveV3AdapterFactory aaveV3AdapterFactory: "0xdeCc46a4b09162f5369c5c80383aaa9159bcf192", // verified on Gnosisscan diff --git a/src/chains/index.ts b/src/chains/index.ts index b3e2568..fcc50b1 100644 --- a/src/chains/index.ts +++ b/src/chains/index.ts @@ -44,7 +44,7 @@ export const ALL_DEFINED_CHAINS = [ * ponder.config.ts derives all RPC/contract config from this array. */ export const ACTIVE_CHAINS = [ - // mainnet, + mainnet, gnosis, // arbitrum, // fully verified — enable when ARBITRUM_RPC_URL is provisioned // base, // fully verified — enable when BASE_RPC_URL is provisioned diff --git a/src/chains/mainnet.ts b/src/chains/mainnet.ts index 7fdebb2..58a5b25 100644 --- a/src/chains/mainnet.ts +++ b/src/chains/mainnet.ts @@ -10,18 +10,18 @@ export const mainnet: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", - startBlock: 25319424, + startBlock: 17883049, }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", }, cowShedFactory: { address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", - startBlock: 25319424, + startBlock: 22939254, }, gpv2Settlement: { address: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", - startBlock: 23928242, + startBlock: 23812751, // AaveV3AdapterFactory deployment block (Nov 16, 2025) }, flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", aaveV3AdapterFactory: "0xdeCc46a4b09162f5369c5c80383aaa9159bcf192", diff --git a/src/constants.ts b/src/constants.ts index 7e39bee..ce3972d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -75,6 +75,7 @@ export const BLOCK_HANDLER_RPC_TIMEOUT_MS = 15_000; // Tighter cap for cheap inner-loop calls (getCode, eth_call) in the settlement handler. // The outer receipt fetch and readContract(owner()) keep the full 15 s. export const SETTLEMENT_INNER_RPC_TIMEOUT_MS = 5_000; + /** * Hard wall-clock cap for the whole per-owner bootstrap fetch in OwnerBackfill * (account pagination + by_uids refresh). Owners that exceed this are skipped; From bfad24138dc99d7c2dc67422a16362f151778bad Mon Sep 17 00:00:00 2001 From: Jefferson Bastos Date: Mon, 15 Jun 2026 15:55:52 -0300 Subject: [PATCH 38/84] chore(qa): remove Linear ticket IDs from code comments and tests Strips internal Linear ticket references (COW-918/979/988/990/993/1003) from comments and test descriptions ahead of the public handover. The surrounding explanations are kept; only the ticket IDs are removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/application/handlers/blockHandler.ts | 4 ++-- tests/api/orders-by-owner.test.ts | 4 ++-- tests/constants.test.ts | 2 +- tests/helpers/orderbookClient.test.ts | 2 +- tests/helpers/statusFilter.test.ts | 2 +- tests/utils/order-types.test.ts | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 33b7c02..14301e1 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -349,7 +349,7 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { }[]; if (orphanCandidates.length > 0) { - // COW-990: preflight /by_uids before writing cancelled. A candidate could have + // 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. @@ -870,7 +870,7 @@ ponder.on("OwnerBackfill:block", async ({ event, context }) => { // for them. This handler closes that gap by reading // ComposableCoW.singleOrders(owner, hash) on a DETERMINISTIC_CANCEL_SWEEP_INTERVAL // cadence. A `false` result means the owner called remove() on-chain → flip to -// Cancelled, which lets the C2/C3 parent-cancelled cascade (COW-918) reconcile +// Cancelled, which lets the C2/C3 parent-cancelled cascade reconcile // the child discrete / candidate rows on the next block. ponder.on("CancellationWatcher:block", async ({ event, context }) => { diff --git a/tests/api/orders-by-owner.test.ts b/tests/api/orders-by-owner.test.ts index 57a5ce1..707ec71 100644 --- a/tests/api/orders-by-owner.test.ts +++ b/tests/api/orders-by-owner.test.ts @@ -173,7 +173,7 @@ describe("ordersByOwnerHandler", () => { }); }); -// ─── Schema tests (COW-993) ────────────────────────────────────────────────── +// ─── Schema tests ────────────────────────────────────────────────── // A minimal valid GeneratorSummary payload that satisfies all required fields. const validGenerator = { @@ -188,7 +188,7 @@ const validGenerator = { } as const; describe("GeneratorSummary schema", () => { - // Regression guard for COW-993: hash was previously missing from the schema, + // Regression guard: hash was previously missing from the schema, // causing it to be silently dropped from API responses. safeParse accepts // unknown so TS gives no protection here at runtime. it("fails parse when hash is missing", () => { diff --git a/tests/constants.test.ts b/tests/constants.test.ts index 1a778d9..4799405 100644 --- a/tests/constants.test.ts +++ b/tests/constants.test.ts @@ -15,7 +15,7 @@ import { DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK, } from "../src/constants"; -describe("DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK (COW-988)", () => { +describe("DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK", () => { it("is 200", () => { expect(DEFAULT_MAX_DISCRETE_ORDERS_PER_BLOCK).toBe(200); }); diff --git a/tests/helpers/orderbookClient.test.ts b/tests/helpers/orderbookClient.test.ts index 56cc98a..b1afc95 100644 --- a/tests/helpers/orderbookClient.test.ts +++ b/tests/helpers/orderbookClient.test.ts @@ -133,7 +133,7 @@ describe("fetchOrderStatusByUids", () => { expect(result.size).toBe(0); }); - it("correctly unwraps the { order } wrapper and maps uid → status (regression: COW-979)", async () => { + it("correctly unwraps the { order } wrapper and maps uid → status", async () => { const { url, close } = await startServer((_req, res) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify([makeWrappedOrder(UID_A, "fulfilled")])); diff --git a/tests/helpers/statusFilter.test.ts b/tests/helpers/statusFilter.test.ts index 5d461bb..929d903 100644 --- a/tests/helpers/statusFilter.test.ts +++ b/tests/helpers/statusFilter.test.ts @@ -1,5 +1,5 @@ /** - * Tests for the C3 StatusUpdater row-building filter logic (COW-988). + * Tests for the C3 StatusUpdater row-building filter logic. * * blockHandler.ts defines a module-level constant: * const VALID_DISCRETE_STATUSES = new Set(["fulfilled", "unfilled", "expired", "cancelled"]); diff --git a/tests/utils/order-types.test.ts b/tests/utils/order-types.test.ts index ce92226..5d145c5 100644 --- a/tests/utils/order-types.test.ts +++ b/tests/utils/order-types.test.ts @@ -15,7 +15,7 @@ describe("DETERMINISTIC_ORDER_TYPE", () => { it("marks TWAP, StopLoss, CirclesBackingOrder as deterministic", () => { expect(DETERMINISTIC_ORDER_TYPE["TWAP"]).toBe(true); expect(DETERMINISTIC_ORDER_TYPE["StopLoss"]).toBe(true); - // Regression guard for COW-1003: CirclesBackingOrder must be deterministic + // Regression guard: CirclesBackingOrder must be deterministic expect(DETERMINISTIC_ORDER_TYPE["CirclesBackingOrder"]).toBe(true); }); From 23da85a44c3749c96b04823f3125d89e462c72c0 Mon Sep 17 00:00:00 2001 From: Jefferson Bastos Date: Mon, 15 Jun 2026 15:55:52 -0300 Subject: [PATCH 39/84] chore(qa): remove build-phase context and local paths from code comments orderUid.ts: drop the 'Phase 2' build-phase note and point the GPv2Order.sol reference at the public cowprotocol/contracts repo instead of a local tmp/ path. good-after-time.ts: replace the 'opaque in M1' milestone note with a description of the field. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/application/helpers/orderUid.ts | 3 +-- src/decoders/good-after-time.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/application/helpers/orderUid.ts b/src/application/helpers/orderUid.ts index 97b1f15..fdff5c2 100644 --- a/src/application/helpers/orderUid.ts +++ b/src/application/helpers/orderUid.ts @@ -4,8 +4,7 @@ * UID = abi.encodePacked(orderDigest, owner, uint32(validTo)) * where orderDigest = EIP-712 typed hash of the GPv2Order struct. * - * Reference: GPv2Order.sol (tmp/contracts/gpv2-contracts/src/contracts/libraries/GPv2Order.sol) - * Added during orderbook cache refactor (Phase 2) + * Reference: GPv2Order.sol (cowprotocol/contracts) */ import { encodePacked, hashTypedData, type Hex } from "viem"; diff --git a/src/decoders/good-after-time.ts b/src/decoders/good-after-time.ts index b690ebf..c92ebe7 100644 --- a/src/decoders/good-after-time.ts +++ b/src/decoders/good-after-time.ts @@ -9,7 +9,7 @@ export interface GoodAfterTimeDecodedParams { startTime: bigint; endTime: bigint; allowPartialFill: boolean; - priceCheckerPayload: string; // hex bytes — opaque in M1 + priceCheckerPayload: string; // hex bytes — opaque (price-checker payload, not decoded) appData: string; } From 4fabcddbdac81c8a3e8e711bf99b05107519ada4 Mon Sep 17 00:00:00 2001 From: Jefferson Bastos Date: Mon, 15 Jun 2026 15:55:52 -0300 Subject: [PATCH 40/84] docs(qa): remove ticket and milestone references from public docs Drops the COW-986 ticket references (the add-a-chain checklist already lives in architecture.md) and the '(M3 reference)' milestone label from the PollResultErrors heading. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/api-reference.md | 2 +- docs/architecture.md | 2 +- docs/supported-order-types.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index e605547..236b101 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -151,4 +151,4 @@ The active chain list is `ACTIVE_CHAINS` in `src/chains/index.ts`. Currently act Filter queries with `where: { chainId: 1 }` (GraphQL) or `?chainId=1` (REST). -> Adding a chain: create `src/chains/.ts` following the existing chain files as a template, add it to `ACTIVE_CHAINS` in `src/chains/index.ts`, and add its RPC URL env var. See COW-986 for the full checklist. +> Adding a chain: create `src/chains/.ts` following the existing chain files as a template, add it to `ACTIVE_CHAINS` in `src/chains/index.ts`, and add its RPC URL env var. diff --git a/docs/architecture.md b/docs/architecture.md index d747c0d..c025909 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,7 +14,7 @@ Configuration lives in `src/chains/` (one file per chain). The ComposableCoW con Currently active chains, their start blocks, and contract addresses are defined in `src/chains/`. To add a chain, create a chain file there and register it in `src/chains/index.ts`. -Stub configs exist for all 12 chains in cow-sdk's `ALL_SUPPORTED_CHAIN_IDS`; contract addresses for the remaining chains need verification before enabling (see COW-986). +Stub configs exist for all 12 chains in cow-sdk's `ALL_SUPPORTED_CHAIN_IDS`; contract addresses for the remaining chains need verification before enabling. `ponder.config.ts` derives all config from `ACTIVE_CHAINS` in `src/chains/index.ts` and wires it into Ponder's `createConfig`. It never contains raw addresses or block numbers directly. It also registers the five live-only block handlers from `blockHandler.ts` (`OrderDiscoveryPoller`, `CandidateConfirmer`, `OrderStatusTracker`, `OwnerBackfill`, `CancellationWatcher`) — all run during live sync only (`startBlock: "latest"`). diff --git a/docs/supported-order-types.md b/docs/supported-order-types.md index d64a4ed..7c617fd 100644 --- a/docs/supported-order-types.md +++ b/docs/supported-order-types.md @@ -265,7 +265,7 @@ When `staticInput` cannot be decoded for a known order type, the indexer stores For `Unknown` order types (handler address not in the registry), `decodedParams` is null and `decodeError` is null. The raw `staticInput` hex is always available on the `conditional_order_generator` record regardless of decode outcome. -## PollResultErrors (M3 reference) +## PollResultErrors The ComposableCoW system uses poll result codes to coordinate when orders should be re-checked. These are relevant if you're working on the block handler that polls `getTradableOrder`: From 1a2879f9c762a8a1a9e4cb31ec14c1554e37b377 Mon Sep 17 00:00:00 2001 From: Jefferson Bastos Date: Mon, 15 Jun 2026 15:55:52 -0300 Subject: [PATCH 41/84] docs(qa): scrub internal references from agent docs Removes the slack_decisions_summary.md and local thoughts/ table rows plus a dangling architecture.md row from AGENTS.md; drops M1/M3 milestone tags, a local PoC path, the 'written for team discussion' note, and a private debug-log reference from agent_docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 5 +---- agent_docs/code-patterns.md | 2 +- agent_docs/m3-orderbook-integration-flow.md | 4 ++-- agent_docs/project-structure.md | 7 +++---- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0140b55..3015fce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,13 +43,10 @@ Start PostgreSQL with `docker compose up -d` to use it instead of the default SQ | File | When to read | |------|--------------| -| `agent_docs/architecture.md` | Full data-flow, file responsibilities, schema details | | `agent_docs/project-structure.md` | Current file map, schema tables, env vars, key commands | | `agent_docs/code-patterns.md` | Schema/naming conventions (snake_case, composite PK, eventId) — **check before schema or handler changes** | | `agent_docs/token-indexer-overview.md` | Full Ponder patterns (handlers, repos, services) — **read before writing any implementation plan** | -| `docs/supported-order-types.md` | All 5 order type ABI structs, handler addresses, decoded fields, edge cases — **read before any decoder or M3 block-handler work** | -| `agent_docs/slack_decisions_summary.md` | Technical decisions from CoW Protocol team (flash loans, CoWShed, orderbook, scope) | -| `thoughts/` (local) | Local working notes, plans, task context (not in repo; see `.claude/commands/` for workflow) | +| `docs/supported-order-types.md` | All 5 order type ABI structs, handler addresses, decoded fields, edge cases — **read before any decoder or block-handler work** | ## Working Conventions diff --git a/agent_docs/code-patterns.md b/agent_docs/code-patterns.md index 55ea518..a956a44 100644 --- a/agent_docs/code-patterns.md +++ b/agent_docs/code-patterns.md @@ -38,7 +38,7 @@ Every `fetch`, `context.client.multicall`, `context.client.readContract`, or oth 2. Catch `TimeoutError` at the handler boundary, log `[COW:Cx]