Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ All block handlers run only during live sync (`startBlock: "latest"`) to avoid h

**CandidateConfirmer** (every block, mainnet + gnosis): First drains any `candidateDiscreteOrder` rows whose parent generator is `Cancelled` — promoting them into `discreteOrder` with `status='cancelled'` and deleting the candidate rows. Then checks remaining `candidateDiscreteOrder` rows against the orderbook API: when a candidate appears in the API, it's promoted to `discreteOrder` and deleted from candidates. Candidates past their `validTo` are also pruned.

**TWAP aged-out fallback**: When a candidate's `orderUid` is no longer served by `/orders/by_uids` (typically after the order expires from the orderbook cache), `CandidateConfirmer` falls back to fetching the owner's full order list from `/account/{owner}/orders`. This resolves TWAP parts that the orderbook stopped tracking before C2 processed them. On timeout or API failure, the candidate defaults to `expired`.

**OrderStatusTracker** (every block, mainnet + gnosis): Polls the orderbook API for all `open` discrete orders and updates their status from the API response. Then sweeps any remaining `open` rows whose parent generator is `Cancelled` to `status='cancelled'` (API-terminal statuses from the loop above still win for children that were traded before on-chain cancellation). Finally expires any orders past their `validTo` timestamp.

**OwnerBackfill** (fires once at latest block, mainnet + gnosis): One-time fetch of historical orders for non-deterministic generators (PerpetualSwap, GoodAfterTime, TradeAboveThreshold, Unknown) that were active during backfill but have no discrete orders yet. Queries the CoW Protocol `/orders?owner=` endpoint per owner.
Expand Down
43 changes: 42 additions & 1 deletion src/application/handlers/blockHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
TRY_NEXT_BLOCK_BACKOFF_MID,
TRY_NEXT_BLOCK_BACKOFF_COLD,
} from "../../constants";
import { fetchComposableOrders, fetchOrderStatusByUids, upsertDiscreteOrders } from "../helpers/orderbookClient";
import { fetchComposableOrders, fetchOrderStatusByUids, fetchOwnerOrderStatuses, upsertDiscreteOrders } from "../helpers/orderbookClient";
import { TimeoutError, withTimeout } from "../helpers/withTimeout";
import {
GET_TRADEABLE_ORDER_WITH_ERRORS_ABI,
Expand Down Expand Up @@ -529,6 +529,47 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => {

if (stale.length > 0) {
const staleStatuses = await fetchOrderStatusByUids(context, chainId, stale.map((c) => c.orderUid));

// TWAP parts can age out of /by_uids before C2 sees them, causing fulfilled
// parts to be recorded as "expired". For any missed UIDs, fall back to
// /account/{owner}/orders — one fetch per unique owner.
const missed = stale.filter((c) => !staleStatuses.has(c.orderUid));
if (missed.length > 0) {
const generatorIds = [...new Set(missed.map((c) => c.generatorId))];
const ownerRows = (await context.db.sql
.select({ eventId: conditionalOrderGenerator.eventId, owner: conditionalOrderGenerator.owner })
.from(conditionalOrderGenerator)
.where(inArray(conditionalOrderGenerator.eventId, generatorIds))) as {
eventId: string;
owner: string;
}[];
const ownerByGeneratorId = new Map(ownerRows.map((g) => [g.eventId, g.owner as Hex]));

const missedByOwner = new Map<Hex, Set<string>>();
for (const c of missed) {
const owner = ownerByGeneratorId.get(c.generatorId);
if (!owner) continue;
const ownerKey = owner.toLowerCase() as Hex;
if (!missedByOwner.has(ownerKey)) missedByOwner.set(ownerKey, new Set());
missedByOwner.get(ownerKey)!.add(c.orderUid);
}

for (const [owner, ownerMissedUids] of missedByOwner) {
try {
const ownerStatuses = await withTimeout(
fetchOwnerOrderStatuses(chainId, owner),
BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS,
"c2:stale:accountFallback",
);
for (const [uid, info] of ownerStatuses) {
if (ownerMissedUids.has(uid)) staleStatuses.set(uid, info);
}
} catch (err) {
console.warn(`[COW:C2] block=${event.block.number} chain=${chainId} accountFallback failed owner=${owner}`, err);
}
}
}

const staleRows: (typeof discreteOrder.$inferInsert)[] = stale.map((c) => {
const entry = staleStatuses.get(c.orderUid);
return {
Expand Down
31 changes: 30 additions & 1 deletion src/application/helpers/orderbookClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,15 +293,42 @@ export async function fetchOrderStatusByUids(
return result;
}

/**
* Fallback status lookup via GET /account/{owner}/orders.
* Used when /orders/by_uids returns nothing for UIDs that may have aged out
* of the API's retention window (e.g. TWAP parts near or past validTo).
* Returns a Map of uid -> OrderStatusInfo for all orders found for this owner.
*/
export async function fetchOwnerOrderStatuses(
chainId: number,
owner: Hex,
maxPages = 3,
): Promise<Map<string, OrderStatusInfo>> {
const result = new Map<string, OrderStatusInfo>();
const apiBaseUrl = ORDERBOOK_API_URLS[chainId];
if (!apiBaseUrl) return result;
const orders = await fetchAccountOrders(apiBaseUrl, owner, maxPages);
for (const order of orders) {
result.set(order.uid, {
status: order.status,
executedSellAmount: order.executedSellAmount,
executedBuyAmount: order.executedBuyAmount,
});
}
return result;
}

// ─── API calls ───────────────────────────────────────────────────────────────

/** Fetch all orders for an owner with pagination. */
/** Fetch orders for an owner with pagination. maxPages limits how many pages are fetched (0 = unlimited). */
async function fetchAccountOrders(
apiBaseUrl: string,
owner: Hex,
maxPages = 0,
): Promise<OrderbookOrder[]> {
const allOrders: OrderbookOrder[] = [];
let offset = 0;
let pagesFetched = 0;

// eslint-disable-next-line no-constant-condition
while (true) {
Expand All @@ -319,7 +346,9 @@ async function fetchAccountOrders(
}
const page = (await response.json()) as OrderbookOrder[];
allOrders.push(...page);
pagesFetched++;
if (page.length < PAGE_LIMIT) break; // last page
if (maxPages > 0 && pagesFetched >= maxPages) break; // page cap reached
offset += page.length;
} catch (err) {
if (err instanceof TimeoutError) {
Expand Down
Loading
Loading