diff --git a/sdk/packages/indexer/docs/ai/ChangeLog.md b/sdk/packages/indexer/docs/ai/ChangeLog.md index bc0ba02c9..7a4fac2c4 100644 --- a/sdk/packages/indexer/docs/ai/ChangeLog.md +++ b/sdk/packages/indexer/docs/ai/ChangeLog.md @@ -12,6 +12,27 @@ Files: list of files touched. Newest entries first. +## 2026-08-28 — Index the gateway's OrderCancelled event + +`09888bd1` added `OrderCancelled(bytes32 indexed commitment, address canceller)` to IntentGatewayV2. It was the +only contract event with no counterpart in the indexer: the ABI already carried the other reshaped events from +this cycle (`OrderFilled`/`PartialFill`/`EscrowReleased`/`EscrowRefunded` with their token arrays, `DeploymentAdded`, +`DestinationProtocolFeeUpdated`), so cancellation was the whole gap. + +Added a `CANCELLED` order status, an `IOrderV3Cancellation` entity recording who cancelled and where, a handler, +and the datasource wiring. The `canceller` is worth storing separately from the order's `user`: the +destination-side cancel route is permissionless once the order has expired, so the two are not the same account +in general. + +`recordOrderCancellation` only advances the status from `PLACED` — `updateOrderStatus` overwrites unconditionally, +and a cross-chain cancel is initiated on the destination chain while its refund lands on the source chain, so +without the guard a late-indexed cancellation would clobber a `REFUNDED` that had already been recorded. See +Decisions for why the guard sits in the service rather than in `updateOrderStatus`. + +Files: `src/configs/abis/IntentGatewayV3.abi.json`, `src/configs/schema.graphql`, +`src/services/intentGatewayV3.service.ts`, `src/handlers/events/intentGatewayV3/orderCancelledV3.event.handler.ts`, +`src/mappings/mappingHandlers.ts`, `scripts/templates/evm-chain.yaml.hbs`. + ## 2026-08-19 — Pool rates renormalize by the leg's own standard amount `resolvePoolLeg` used to require `standardAmount === 10 ** inputDecimals` exactly, and `updateLiquidityPools` derived a chain sample as `medianPrice * scale`, which silently assumes that same one-unit probe. Both now work for whatever standard amount the phantom order carries: the rate is `medianPrice * scale * 10 ** inDecimals / standardAmount`, exact for any probe size, whole-token or not, and it collapses to the old expression when the probe is one unit. Verified against production: the four live one-unit rates (Base cNGN/USDC both directions, BSC cNGN/USDT both directions) reproduce byte-for-byte, and a 1000x probe with a 1000x quote yields the identical rate. diff --git a/sdk/packages/indexer/docs/ai/Decisions.md b/sdk/packages/indexer/docs/ai/Decisions.md index 6da412522..be7f87fa4 100644 --- a/sdk/packages/indexer/docs/ai/Decisions.md +++ b/sdk/packages/indexer/docs/ai/Decisions.md @@ -4,6 +4,35 @@ AI-maintained record of non-obvious choices made in `sdk/packages/indexer`: what Entry format: heading with the decision, then alternatives considered and the reasoning. Newest first. +## 2026-08-28 — `CANCELLED` is a real status, but only reachable from `PLACED` + +`OrderCancelled` marks the *initiation* of a cancellation, not its completion. The gateway's own docs are +explicit that `EscrowRefunded` stays terminal: it follows in the same transaction for a same-chain cancel, and on +the source chain once the cancellation has travelled through Hyperbridge for a cross-chain one. An order can also +sit cancelled forever — the source-side route re-emits on every call and only refunds when the GET response +returns. + +Two alternatives were rejected: + +- **Record the event but never touch `status`.** Safest, but leaves a cancelled cross-chain order reading `PLACED` + for as long as the refund takes, which is exactly the window an API consumer most wants to see. +- **Set `CANCELLED` unconditionally.** `updateOrderStatus` assigns without comparing against the current value, + and the destination-chain cancel and source-chain refund are indexed by two datasources with no ordering + guarantee between them. A cancellation indexed after its own refund would move a settled order back to + `CANCELLED`. + +So the write is guarded on the current status being `PLACED`. That makes it idempotent and order-independent — +whichever of the pair is processed second is dropped if it would regress — without changing behaviour for any +other status. + +The guard lives in `recordOrderCancellation`, not in `updateOrderStatus`. Adding a general "never regress" rule to +`updateOrderStatus` would be the broader fix, but it would silently change every existing transition (including +the `FILLED` → points path) on a code path with no test coverage for ordering. Narrow guard now; the general rule +is a separate change with its own justification. + +An order not yet seen falls through to `updateOrderStatus`'s existing `PendingStatusMetadata` path, which is +already how out-of-order arrival is handled everywhere else in this service. + ## 2026-08-19 — The standard-amount check bounds plausibility instead of pinning one unit Chosen: `resolvePoolLeg` accepts any standard amount within a plausibility window around one whole input token, and `updateLiquidityPools` renormalizes the rate by the leg's own standard amount. The pallet is then free to raise the probe size to buy quote precision without the indexer rescaling every published rate by that factor. diff --git a/sdk/packages/indexer/docs/ai/Flow.md b/sdk/packages/indexer/docs/ai/Flow.md index 40703e41c..7a524010f 100644 --- a/sdk/packages/indexer/docs/ai/Flow.md +++ b/sdk/packages/indexer/docs/ai/Flow.md @@ -47,3 +47,28 @@ Verified 2026-08-19 against live mainnet data. 5. Chain rows (`PoolChainLiquidity`, one per pool/chain/direction) are merged into the pool's single `sellRate`/`buyRate` by `weightedRate` — a depth-weighted **mean**, which unlike the median in step 3 does produce values no filler quoted. Samples older than `MAX_SAMPLE_AGE_BLOCKS` are excluded unless every sample is stale. Precision note: a leg's quoted output integer *is* the price, to whatever resolution the output token's decimals allow. cNGN into 6-decimal USDC quotes ~715 base units, so the grid is 1/715 = 0.14% and the filler's floor rounding costs up to one full step. Chains whose output token has 18 decimals carry full precision on the same leg — which is why EVM-56 publishes `716845878136200` where Base publishes a bare `715`. The fix is a larger `standardAmount`, which step 4 now supports; see Decisions.md for why the filler's flooring must stay. + +## Order cancellation (OrderCancelled → EscrowRefunded) + +Verified by reading the handler, the service, and the gateway's event docs; the two-event split is what makes the +status guard necessary. + +1. `cancelOrder` on IntentGatewayV2 emits `OrderCancelled(commitment, canceller)` on the chain the cancellation is + initiated from. This is *not* terminal. +2. The log triggers `handleOrderCancelledEventV3` + (`src/handlers/events/intentGatewayV3/orderCancelledV3.event.handler.ts`), which resolves the block timestamp + and calls `IntentGatewayV3Service.recordOrderCancellation`. +3. That method always writes an `IOrderV3Cancellation` row keyed `{transactionHash}.{logIndex}`, so repeat + cancellations each get their own record. It then advances the order to `CANCELLED` **only if the order is + currently `PLACED`** — see Decisions for why. +4. `EscrowRefunded` is the terminal event and moves the order to `REFUNDED` via the existing + `handleEscrowRefundedEventV3`. Two timings: + - **Same-chain cancel:** both logs are in one transaction, `OrderCancelled` at the lower `logIndex` + (`_cancelSameChain` emits before `_withdraw`). The order passes through `CANCELLED` to `REFUNDED` in the same + block. + - **Cross-chain cancel:** `OrderCancelled` fires on the destination chain, and `EscrowRefunded` on the source + chain only once the cancellation has travelled through Hyperbridge. These are separate datasources with no + ordering guarantee, which is the case the `PLACED` guard exists for. +5. A cancellation initiated from the source side can emit `OrderCancelled` and never be followed by + `EscrowRefunded` — the route re-emits on every call and only refunds when the GET response returns. An order + resting at `CANCELLED` is therefore an expected steady state, not necessarily an indexing gap. diff --git a/sdk/packages/indexer/scripts/templates/evm-chain.yaml.hbs b/sdk/packages/indexer/scripts/templates/evm-chain.yaml.hbs index 27b0327ee..8781f2618 100644 --- a/sdk/packages/indexer/scripts/templates/evm-chain.yaml.hbs +++ b/sdk/packages/indexer/scripts/templates/evm-chain.yaml.hbs @@ -72,6 +72,11 @@ dataSources: filter: topics: - 'EscrowRefunded(bytes32,(bytes32,uint256)[])' + - kind: ethereum/LogHandler + handler: handleOrderCancelledEventV3 + filter: + topics: + - 'OrderCancelled(bytes32,address)' - kind: ethereum/LogHandler handler: handleDustCollectedEventV3 filter: diff --git a/sdk/packages/indexer/src/configs/abis/IntentGatewayV3.abi.json b/sdk/packages/indexer/src/configs/abis/IntentGatewayV3.abi.json index 862b6c70c..44b2553a5 100644 --- a/sdk/packages/indexer/src/configs/abis/IntentGatewayV3.abi.json +++ b/sdk/packages/indexer/src/configs/abis/IntentGatewayV3.abi.json @@ -1120,6 +1120,25 @@ ], "anonymous": false }, + { + "type": "event", + "name": "OrderCancelled", + "inputs": [ + { + "name": "commitment", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "canceller", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, { "type": "event", "name": "DustSwept", diff --git a/sdk/packages/indexer/src/configs/schema.graphql b/sdk/packages/indexer/src/configs/schema.graphql index 3d5163fca..ca6fa9c8a 100644 --- a/sdk/packages/indexer/src/configs/schema.graphql +++ b/sdk/packages/indexer/src/configs/schema.graphql @@ -45,6 +45,15 @@ enum OrderStatus { """ REDEEMED """ + Cancellation has been initiated, but the escrow has not been refunded yet. + + Not terminal: REFUNDED follows, in the same transaction for a same-chain cancel and on + the source chain once the cancellation has travelled through Hyperbridge for a + cross-chain one. An order can also sit in this state permanently — the source-side + route re-emits on every call and the refund only lands when the GET response returns. + """ + CANCELLED + """ The order has been cancelled and refunded on the source chain """ REFUNDED @@ -856,6 +865,13 @@ type IOrderV3 @entity { """ escrowRefunds: [IOrderV3EscrowRefund!]! @derivedFrom(field: "order") + """ + Cancellation events for this order. More than one is possible: the source-side route + re-emits on every call, and a cross-chain cancel is initiated on the destination chain + while the refund lands on the source chain. + """ + cancellations: [IOrderV3Cancellation!]! @derivedFrom(field: "order") + """ Predispatch assets for this order """ @@ -1365,6 +1381,54 @@ type IOrderV3EscrowReleaseToken @entity { index: Int! } +""" +Represents an IOrderV3 OrderCancelled event — the initiation of a cancellation, on the +chain it was initiated from. The terminal event is EscrowRefunded, not this one. +""" +type IOrderV3Cancellation @entity { + """ + Unique identifier for the cancellation event. Format: {transactionHash}.{logIndex} + """ + id: ID! + + """ + The order whose cancellation was initiated + """ + order: IOrderV3! + + """ + The chain on which the cancellation was initiated. The source chain for a same-chain + cancel, the destination chain for the permissionless post-expiry route. + """ + chain: String! @index + + """ + The account that initiated the cancellation. The destination-side route is + permissionless after expiry, so this is not necessarily the order's creator. + """ + canceller: String! @index + + """ + The timestamp of the cancellation event + """ + timestamp: BigInt! @index + + """ + The number of the block in which the cancellation occurred + """ + blockNumber: String! @index + + """ + The hash of the transaction in which the cancellation occurred + """ + transactionHash: String! @index + + """ + The timestamp when this record was created + """ + createdAt: Date! @index +} + """ Represents an EscrowRefunded event for an IOrderV3 — escrowed tokens refunded to the user after cancellation. Fires on the source chain. diff --git a/sdk/packages/indexer/src/handlers/events/intentGatewayV3/orderCancelledV3.event.handler.ts b/sdk/packages/indexer/src/handlers/events/intentGatewayV3/orderCancelledV3.event.handler.ts new file mode 100644 index 000000000..daaf50c16 --- /dev/null +++ b/sdk/packages/indexer/src/handlers/events/intentGatewayV3/orderCancelledV3.event.handler.ts @@ -0,0 +1,40 @@ +import { getBlockTimestamp } from "@/utils/rpc.helpers" +import stringify from "safe-stable-stringify" +import { OrderCancelledLog } from "@/configs/src/types/abi-interfaces/IntentGatewayV3Abi" +import { IntentGatewayV3Service } from "@/services/intentGatewayV3.service" +import { getHostStateMachine } from "@/utils/substrate.helpers" +import { wrap } from "@/utils/event.utils" + +/** + * Handles OrderCancelled, emitted when a cancellation is *initiated* on the chain it is + * initiated from. EscrowRefunded remains the terminal event and is what moves the order to + * REFUNDED — it follows in the same transaction for a same-chain cancel, and on the source + * chain once the cancellation has travelled through Hyperbridge for a cross-chain one. + * + * The status write is guarded inside the service so this cannot regress an order that has + * already been refunded; see `recordOrderCancellation`. + */ +export const handleOrderCancelledEventV3 = wrap(async (event: OrderCancelledLog): Promise => { + logger.info(`[Intent Gateway V3] Order Cancelled Event: ${stringify(event)}`) + + const { blockNumber, transactionHash, args, blockHash, logIndex } = event + if (!args) return + const { commitment, canceller } = args + + const chain = getHostStateMachine(chainId) + const timestamp = await getBlockTimestamp(blockHash, chain) + + logger.info( + `[Intent Gateway V3] Order Cancelled: ${stringify({ + commitment, + canceller, + })}`, + ) + + await IntentGatewayV3Service.recordOrderCancellation(commitment, canceller, { + transactionHash, + blockNumber, + timestamp, + logIndex, + }) +}) diff --git a/sdk/packages/indexer/src/mappings/mappingHandlers.ts b/sdk/packages/indexer/src/mappings/mappingHandlers.ts index 1105a717a..c1280b544 100644 --- a/sdk/packages/indexer/src/mappings/mappingHandlers.ts +++ b/sdk/packages/indexer/src/mappings/mappingHandlers.ts @@ -16,6 +16,7 @@ export { handleOrderFilledEventV3 } from "@/handlers/events/intentGatewayV3/orde export { handlePartialFilledEventV3 } from "@/handlers/events/intentGatewayV3/partialFilledV3.event.handler" export { handleEscrowReleasedEventV3 } from "@/handlers/events/intentGatewayV3/escrowReleasedV3.event.handler" export { handleEscrowRefundedEventV3 } from "@/handlers/events/intentGatewayV3/escrowRefundedV3.event.handler" +export { handleOrderCancelledEventV3 } from "@/handlers/events/intentGatewayV3/orderCancelledV3.event.handler" export { handleDustCollectedEventV3 } from "@/handlers/events/intentGatewayV3/dustCollected.event.handler" export { handleDustSweptEventV3 } from "@/handlers/events/intentGatewayV3/dustSwept.event.handler" diff --git a/sdk/packages/indexer/src/services/intentGatewayV3.service.ts b/sdk/packages/indexer/src/services/intentGatewayV3.service.ts index 91669d1da..6e6a2bbb9 100644 --- a/sdk/packages/indexer/src/services/intentGatewayV3.service.ts +++ b/sdk/packages/indexer/src/services/intentGatewayV3.service.ts @@ -31,6 +31,7 @@ import { IOrderV3FillInputAsset } from "@/configs/src/types/models/IOrderV3FillI import { IOrderV3FillOutputAsset } from "@/configs/src/types/models/IOrderV3FillOutputAsset" import { IOrderV3EscrowRelease } from "@/configs/src/types/models/IOrderV3EscrowRelease" import { IOrderV3EscrowReleaseToken } from "@/configs/src/types/models/IOrderV3EscrowReleaseToken" +import { IOrderV3Cancellation } from "@/configs/src/types/models/IOrderV3Cancellation" import { IOrderV3EscrowRefund } from "@/configs/src/types/models/IOrderV3EscrowRefund" import { IOrderV3EscrowRefundToken } from "@/configs/src/types/models/IOrderV3EscrowRefundToken" import { IntentGatewayTokenVolume } from "@/configs/src/types/models/IntentGatewayTokenVolume" @@ -916,6 +917,64 @@ export class IntentGatewayV3Service { ) } + /** + * Records an OrderCancelled event and advances the order to CANCELLED, but only from + * PLACED. + * + * The gateway documents EscrowRefunded — not this event — as terminal, and + * `updateOrderStatus` overwrites unconditionally, so calling it unguarded would let a + * cancellation clobber a REFUNDED that has already landed. That ordering is not + * hypothetical: a cross-chain cancel is initiated on the destination chain and refunded + * on the source chain, and the two are indexed by separate datasources with no ordering + * guarantee between them. Guarding on PLACED makes the write idempotent and + * order-independent — whichever arrives second is ignored if it would regress. + * + * An order not yet seen falls through to `updateOrderStatus`'s PendingStatusMetadata + * path, which is the existing mechanism for out-of-order arrival. + */ + static async recordOrderCancellation( + commitment: string, + canceller: string, + logsData: { + transactionHash: string + blockNumber: number + timestamp: bigint + logIndex: number + }, + ): Promise { + const { transactionHash, blockNumber, timestamp, logIndex } = logsData + const cancellationId = `${transactionHash}.${logIndex}` + + let cancellation = await IOrderV3Cancellation.get(cancellationId) + if (!cancellation) { + cancellation = await IOrderV3Cancellation.create({ + id: cancellationId, + orderId: commitment, + chain: chainId, + canceller, + timestamp, + blockNumber: blockNumber.toString(), + transactionHash, + createdAt: timestampToDate(timestamp), + }) + } + await cancellation.save() + + const orderPlaced = await OrderV3Placed.get(commitment) + if (orderPlaced && orderPlaced.status !== OrderStatus.PLACED) { + logger.info( + `[Intent Gateway V3] Order ${commitment} already at ${orderPlaced.status}; not regressing to CANCELLED`, + ) + return + } + + await IntentGatewayV3Service.updateOrderStatus(commitment, OrderStatus.CANCELLED, { + transactionHash, + blockNumber, + timestamp, + }) + } + static computeOrderCommitment(order: OrderV3): string { // Legacy DB rows store state-machine ids as hex bytes; new event data // arrives as plain strings (e.g. "EVM-97"). Normalise both to the hex