Skip to content
Draft
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
21 changes: 21 additions & 0 deletions sdk/packages/indexer/docs/ai/ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions sdk/packages/indexer/docs/ai/Decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions sdk/packages/indexer/docs/ai/Flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions sdk/packages/indexer/scripts/templates/evm-chain.yaml.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions sdk/packages/indexer/src/configs/abis/IntentGatewayV3.abi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
64 changes: 64 additions & 0 deletions sdk/packages/indexer/src/configs/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
"""
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> => {
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,
})
})
1 change: 1 addition & 0 deletions sdk/packages/indexer/src/mappings/mappingHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
59 changes: 59 additions & 0 deletions sdk/packages/indexer/src/services/intentGatewayV3.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<void> {
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
Expand Down
Loading