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: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,5 +227,5 @@ See [api-reference.md](./api-reference.md) for the full endpoint list.

## Known Limitations

- Cancellation detection has a small lag. For non-deterministic generators, C1 catches `SingleOrderNotAuthed` on the next poll (every block). For deterministic generators, C5 reads `singleOrders(owner, hash)` every `DETERMINISTIC_CANCEL_SWEEP_INTERVAL` blocks (default 100) — so on-chain removal is reflected with worst-case latency of ~100 blocks (~20 min mainnet, ~8 min Gnosis). There is no on-chain event for `remove()`, so shorter detection latency would require a higher-cadence sweep. Once the generator is marked `Cancelled`, C2 and C3 cascade the state to children on the next block; API-terminal statuses (`fulfilled` / `unfilled` / `expired`) still win for children that were already traded on the orderbook.
- Cancellation detection has a small lag. For non-deterministic generators, C1 catches `SingleOrderNotAuthed` on the next poll (every block). For deterministic generators, C5 reads `singleOrders(owner, hash)` every `DETERMINISTIC_CANCEL_SWEEP_INTERVAL` blocks (default 100) — so on-chain removal is reflected with worst-case latency of ~100 blocks (~20 min mainnet, ~8 min Gnosis). There is no on-chain event for `remove()`, so shorter detection latency would require a higher-cadence sweep. Once the generator is marked `Cancelled`, C2 and C3 cascade the state to children on the next block. The C2 cascade does a preflight `/by_uids` query so that candidates already on the orderbook get their actual status rather than defaulting to `cancelled`; API-terminal statuses (`fulfilled` / `unfilled` / `expired`) still win for children already promoted to `discrete_order`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remember to carefully resolve the conflicts here since other PR is changing the C2 naming

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — resolved carefully. Used develop's src/chains/<name>.ts chain-addition steps and kept the preflight /by_uids mention in Known Limitations.

- Aave adapter owner resolution is reactive — `owner_mapping` is written when the adapter appears in settlement, which may be after the conditional order is created. The generator row keeps `resolvedOwner` equal to the adapter address when no mapping existed at insert time; that column is not backfilled when the mapping is inserted later. `ownerAddressType` on the generator IS backfilled when the mapping is inserted — after which GraphQL and REST filters on `ownerAddressType = "flash_loan_helper"` reflect the correct value. `resolvedOwner` is still not backfilled (set once at insert, unchanged thereafter).
59 changes: 42 additions & 17 deletions src/application/handlers/blockHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

import { ponder } from "ponder:registry";
import { bootstrapRetryQueue, candidateDiscreteOrder, conditionalOrderGenerator, discreteOrder } from "ponder:schema";
import { bootstrapRetryQueue, candidateDiscreteOrder, conditionalOrderGenerator, discreteOrder, discreteOrderStatusEnum } from "ponder:schema";
import { and, asc, eq, inArray, isNull, lte, or, sql } from "ponder";
import type { Hex } from "viem";
import {
Expand All @@ -27,6 +27,7 @@ import {
BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS,
DEFAULT_MAX_GENERATORS_PER_BLOCK,
DETERMINISTIC_CANCEL_SWEEP_INTERVAL,
ORDERBOOK_HTTP_TIMEOUT_MS,
RECHECK_INTERVAL,
TRY_NEXT_BLOCK_WARMUP_THRESHOLD,
TRY_NEXT_BLOCK_COOLDOWN_THRESHOLD,
Expand All @@ -42,6 +43,7 @@ import {
} from "../helpers/pollResultErrors";
import { computeOrderUid, type GPv2OrderData } from "../helpers/orderUid";
import { type OrderType } from "../../utils/order-types";
type DiscreteStatus = (typeof discreteOrderStatusEnum.enumValues)[number];

const NON_DETERMINISTIC_TYPES: readonly OrderType[] = ["PerpetualSwap", "GoodAfterTime", "TradeAboveThreshold", "Unknown"];
const SINGLE_SHOT_NON_DETERMINISTIC: readonly OrderType[] = ["GoodAfterTime", "TradeAboveThreshold"];
Expand Down Expand Up @@ -355,23 +357,46 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => {
}[];

if (orphanCandidates.length > 0) {
// COW-990: preflight /by_uids before writing cancelled. A candidate could have
// been posted by the watch-tower and filled/expired between generator creation
// and the cancellation cascade (~0.17% observed rate). Use the API status when
// available; fall back to 'cancelled' for UIDs not yet on the orderbook.
// Bounded by ORDERBOOK_HTTP_TIMEOUT_MS * 2; on timeout the empty map fallback
// keeps correctness degraded-gracefully (all orphans written as 'cancelled').
let preflightStatuses: Awaited<ReturnType<typeof fetchOrderStatusByUids>>;
try {
preflightStatuses = await withTimeout(
fetchOrderStatusByUids(context, chainId, orphanCandidates.map((c) => c.orderUid)),
ORDERBOOK_HTTP_TIMEOUT_MS * 2,
"c2:cascade:preflight",
);
} catch {
preflightStatuses = new Map();
}

// onConflictDoNothing: if C3 already promoted this UID with a terminal status
// (e.g. 'fulfilled'), the existing row wins and this insert is a no-op.
// preflightKnown counts API hits, not rows actually written.
await context.db.sql
.insert(discreteOrder)
.values(
orphanCandidates.map((c) => ({
orderUid: c.orderUid,
chainId,
conditionalOrderGeneratorId: c.generatorId,
status: "cancelled" as const,
sellAmount: c.sellAmount,
buyAmount: c.buyAmount,
feeAmount: c.feeAmount,
validTo: c.validTo,
creationDate: c.creationDate,
executedSellAmount: null,
executedBuyAmount: null,
promotedAt: event.block.timestamp,
})),
orphanCandidates.map((c) => {
const apiEntry = preflightStatuses.get(c.orderUid);
return {
orderUid: c.orderUid,
chainId,
conditionalOrderGeneratorId: c.generatorId,
status: (apiEntry?.status ?? "cancelled") as DiscreteStatus,
sellAmount: c.sellAmount,
buyAmount: c.buyAmount,
feeAmount: c.feeAmount,
validTo: c.validTo,
creationDate: c.creationDate,
executedSellAmount: apiEntry?.executedSellAmount ?? null,
executedBuyAmount: apiEntry?.executedBuyAmount ?? null,
promotedAt: event.block.timestamp,
};
}),
)
.onConflictDoNothing();

Expand All @@ -387,8 +412,9 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => {
),
);

const preflightKnown = preflightStatuses.size;
console.log(
`[COW:C2] block=${event.block.number} chain=${chainId} parent-cancelled=${orphanCandidates.length}`,
`[COW:C2] c2:parent-cancelled block=${event.block.number} chainId=${chainId} parentCancelled=${orphanCandidates.length} preflightKnown=${preflightKnown}`,
);
}
}
Expand Down Expand Up @@ -429,7 +455,6 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => {
const uids = unconfirmed.map((c) => c.orderUid);
const statuses = await fetchOrderStatusByUids(context, chainId, uids);

type DiscreteStatus = "open" | "fulfilled" | "unfilled" | "expired" | "cancelled";
const rowsToUpsert: (typeof discreteOrder.$inferInsert)[] = [];
const confirmedUids: string[] = [];

Expand Down
Loading