Skip to content

Commit 5cb0746

Browse files
authored
[indexer]: Index BidPlaced events from the intents-coprocessor pallet (#1060)
1 parent c89e4a4 commit 5cb0746

10 files changed

Lines changed: 628 additions & 28 deletions

File tree

sdk/packages/indexer/scripts/templates/substrate-chain.yaml.hbs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ dataSources:
6767
filter:
6868
module: bandwidth
6969
method: TierSet
70+
- handler: handleBidPlaced
71+
kind: substrate/EventHandler
72+
filter:
73+
module: intentsCoprocessor
74+
method: BidPlaced
7075
- handler: handleBridgeTokenSupplyIndexing
7176
kind: substrate/BlockHandler
7277
filter:

sdk/packages/indexer/src/configs/schema.graphql

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2238,6 +2238,55 @@ type PhantomOrder @entity {
22382238
blockTimestamp: Date @index
22392239
}
22402240

2241+
"""
2242+
A bid placed by a filler on an order commitment, recorded from the intents-coprocessor pallet's
2243+
BidPlaced event.
2244+
2245+
The bid payload is read out of the place_bid extrinsic in the same block, falling back to the node's
2246+
offchain storage via intents_getBidsForOrder — that RPC pins the chain head and the offchain entry
2247+
expires, so it only answers for bids still live on the queried node.
2248+
2249+
Fillers may re-bid on the same commitment (the pallet unreserves the previous deposit and emits a
2250+
fresh BidPlaced), so one row is written per event rather than per (commitment, filler) — the bid
2251+
history is preserved. The commitment usually belongs to a PhantomOrder, but place_bid accepts any
2252+
commitment, so the field is a plain reference rather than a foreign key.
2253+
"""
2254+
type FillerBid @entity {
2255+
"""
2256+
Composite identifier: {commitment}-{filler}-{blockNumber}-{eventIdx}. Unique per BidPlaced event,
2257+
so a filler's repeat bids on the same commitment are kept as separate rows.
2258+
"""
2259+
id: ID!
2260+
2261+
"""
2262+
The order commitment bid on — keccak256(abi.encode(Order)). Matches PhantomOrder.id when the bid
2263+
targets a phantom order.
2264+
"""
2265+
commitment: String! @index
2266+
2267+
"""
2268+
SS58 address of the filler that placed the bid.
2269+
"""
2270+
filler: String! @index
2271+
2272+
"""
2273+
The raw bid payload — the SCALE-encoded PackedUserOperation the filler submitted, as hex. Stored
2274+
undecoded so a bid stays fully re-interpretable as the fill ABI evolves, and because the pallet's
2275+
own copy expires. Null when neither the extrinsic nor the RPC yielded it.
2276+
"""
2277+
bidData: String
2278+
2279+
"""
2280+
Hash of the extrinsic that placed the bid, when the event is attributable to one.
2281+
"""
2282+
extrinsicHash: String
2283+
2284+
"""
2285+
Hyperbridge block number the bid was placed at.
2286+
"""
2287+
blockNumber: BigInt! @index
2288+
}
2289+
22412290
"""
22422291
The price snapshot for a phantom order, written once its bid window closes (signalled by the
22432292
PhantomBidWindowExhausted event). It collects all live bids via intents_getBidsForOrder and keeps
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { SubstrateEvent } from "@subql/types"
2+
3+
import { wrap } from "@/utils/event.utils"
4+
import { replaceWebsocketWithHttp } from "@/utils/rpc.helpers"
5+
import { getHostStateMachine } from "@/utils/substrate.helpers"
6+
import { resolveBidData } from "@/utils/bid-data"
7+
import { ENV_CONFIG } from "@/constants"
8+
import { FillerBid } from "@/configs/src/types"
9+
10+
/**
11+
* `pallet-intents-coprocessor :: BidPlaced` — a filler placed a bid on an order commitment.
12+
*
13+
* Payload order:
14+
* 0. filler: AccountId
15+
* 1. commitment: H256
16+
* 2. deposit: Balance
17+
*
18+
* The event carries no bid payload, so it is resolved separately by resolveBidData and stored raw.
19+
*/
20+
export const handleBidPlaced = wrap(async (event: SubstrateEvent): Promise<void> => {
21+
const {
22+
event: { data },
23+
block,
24+
extrinsic,
25+
} = event
26+
27+
const [fillerData, commitmentData] = data
28+
29+
const filler = fillerData.toString()
30+
const commitment = commitmentData.toHex()
31+
const blockNumber = block.block.header.number.toBigInt()
32+
33+
// A filler may re-bid on the same commitment, so the block number and event index are part of the
34+
// key — keyed on (commitment, filler) alone, a repeat bid would overwrite its predecessor.
35+
const id = `${commitment}-${filler}-${blockNumber}-${event.idx}`
36+
if (await FillerBid.get(id)) return
37+
38+
const host = getHostStateMachine(chainId)
39+
40+
const bidData = await resolveBidData({
41+
extrinsic,
42+
commitment,
43+
// The RPC keys bids by the raw AccountId bytes, not the SS58 form stored on the entity.
44+
fillerHex: fillerData.toHex(),
45+
nodeUrl: replaceWebsocketWithHttp(ENV_CONFIG[host] ?? "") || undefined,
46+
})
47+
48+
await FillerBid.create({
49+
id,
50+
commitment,
51+
filler,
52+
bidData,
53+
extrinsicHash: extrinsic?.extrinsic.hash.toString(),
54+
blockNumber,
55+
}).save()
56+
57+
logger.info({ commitment, filler, blockNumber }, `FillerBid indexed${bidData ? "" : " (no bid data)"}`)
58+
})

sdk/packages/indexer/src/mappings/mappingHandlers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export { handleDustSweptEventV3 } from "@/handlers/events/intentGatewayV3/dustSw
2323
export { handleIsmpStateMachineUpdatedEvent } from "@/handlers/events/substrateChains/handleIsmpStateMachineUpdatedEvent.handler"
2424
export { handlePhantomOrderRegistered } from "@/handlers/events/substrateChains/handlePhantomOrderRegistered.handler"
2525
export { handlePhantomOrderPrices } from "@/handlers/events/substrateChains/handlePhantomOrderPrices.handler"
26+
export { handleBidPlaced } from "@/handlers/events/substrateChains/handleBidPlaced.handler"
2627
export { handleSubstratePostRequestTimeoutHandledEvent } from "@/handlers/events/substrateChains/handlePostRequestTimeoutHandledEvent.handler"
2728
export { handleSubstrateRequestEvent } from "@/handlers/events/substrateChains/handleRequestEvent.handler"
2829
export { handleSubstrateResponseEvent } from "@/handlers/events/substrateChains/handleResponseEvent.handler"
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { SubstrateExtrinsic } from "@subql/types"
2+
import { extractUserOpFromExtrinsic } from "@/utils/extrinsic.helpers"
3+
4+
// The user operation is read out of the place_bid extrinsic rather than intents_getBidsForOrder,
5+
// because that RPC pins the chain head internally and the pallet's offchain copy expires — so on any
6+
// backfill the extrinsic is the only source that still has the bid. place_bid can arrive wrapped in
7+
// batch/proxy/sudo, so the walk has to find it at depth, and has to match on commitment: one batch
8+
// may carry bids for several orders, and picking the wrong one would attribute another order's quote
9+
// to this event.
10+
11+
const COMMITMENT = `0x${"11".repeat(32)}`
12+
const OTHER_COMMITMENT = `0x${"22".repeat(32)}`
13+
const USER_OP = "0xdeadbeef"
14+
const OTHER_USER_OP = "0xfeedface"
15+
16+
const hex = (value: string) => ({ toHex: () => value })
17+
18+
const placeBid = (commitment: string, userOp: string) => ({
19+
section: "intentsCoprocessor",
20+
method: "placeBid",
21+
args: [hex(commitment), hex(userOp)],
22+
})
23+
24+
const batch = (calls: unknown[]) => ({
25+
section: "utility",
26+
method: "batchAll",
27+
args: [calls],
28+
})
29+
30+
const proxy = (call: unknown) => ({
31+
section: "proxy",
32+
method: "proxy",
33+
args: [hex("0xaaaa"), hex("0x00"), call],
34+
})
35+
36+
const asExtrinsic = (method: unknown) => ({ extrinsic: { method } }) as unknown as SubstrateExtrinsic
37+
38+
39+
describe("extractUserOpFromExtrinsic", () => {
40+
it("reads the user op from a direct place_bid call", () => {
41+
expect(extractUserOpFromExtrinsic(asExtrinsic(placeBid(COMMITMENT, USER_OP)), COMMITMENT)).toBe(USER_OP)
42+
})
43+
44+
it("finds a place_bid nested in a batch", () => {
45+
const call = batch([placeBid(OTHER_COMMITMENT, OTHER_USER_OP), placeBid(COMMITMENT, USER_OP)])
46+
expect(extractUserOpFromExtrinsic(asExtrinsic(call), COMMITMENT)).toBe(USER_OP)
47+
})
48+
49+
it("finds a place_bid nested in a proxied batch", () => {
50+
const call = proxy(batch([placeBid(COMMITMENT, USER_OP)]))
51+
expect(extractUserOpFromExtrinsic(asExtrinsic(call), COMMITMENT)).toBe(USER_OP)
52+
})
53+
54+
it("returns the user op of the matching commitment, not the first bid in the batch", () => {
55+
const call = batch([placeBid(OTHER_COMMITMENT, OTHER_USER_OP), placeBid(COMMITMENT, USER_OP)])
56+
expect(extractUserOpFromExtrinsic(asExtrinsic(call), OTHER_COMMITMENT)).toBe(OTHER_USER_OP)
57+
})
58+
59+
it("returns undefined when no place_bid matches the commitment", () => {
60+
const call = batch([placeBid(OTHER_COMMITMENT, OTHER_USER_OP)])
61+
expect(extractUserOpFromExtrinsic(asExtrinsic(call), COMMITMENT)).toBeUndefined()
62+
})
63+
64+
it("returns undefined for an unrelated extrinsic", () => {
65+
const call = { section: "balances", method: "transfer", args: [hex("0xaaaa"), hex("0x01")] }
66+
expect(extractUserOpFromExtrinsic(asExtrinsic(call), COMMITMENT)).toBeUndefined()
67+
})
68+
69+
it("returns undefined when the event has no extrinsic", () => {
70+
expect(extractUserOpFromExtrinsic(undefined, COMMITMENT)).toBeUndefined()
71+
})
72+
73+
it("does not loop on a self-referential call tree", () => {
74+
const cyclic: any = { section: "utility", method: "batchAll", args: [] }
75+
cyclic.args = [[cyclic]]
76+
expect(extractUserOpFromExtrinsic(asExtrinsic(cyclic), COMMITMENT)).toBeUndefined()
77+
})
78+
})
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { SubstrateExtrinsic } from "@subql/types"
2+
import { fetchBidsForOrder, setAggregationFetch } from "@hyperbridge/sdk/intents-helpers"
3+
4+
import { safeFetch } from "@/utils/safeFetch"
5+
import { extractUserOpFromExtrinsic } from "@/utils/extrinsic.helpers"
6+
7+
// The SDK's RPC helpers run inside the SubQuery VM2 sandbox, which has no global `fetch`.
8+
setAggregationFetch(safeFetch)
9+
10+
/**
11+
* Falls back to the node's offchain storage for a bid's payload.
12+
*
13+
* intents_getBidsForOrder pins the chain head internally and has no `at` parameter, and the
14+
* underlying offchain entry is node-local and expires, so this only returns anything while the bid
15+
* is still live on the node being queried. It is a best-effort backstop for the extrinsic path, not
16+
* a replacement for it.
17+
*/
18+
async function fetchBidDataFromRpc(nodeUrl: string, commitment: string, fillerHex: string): Promise<string | undefined> {
19+
try {
20+
const bids = await fetchBidsForOrder(nodeUrl, commitment)
21+
const match = bids.find((bid) => bid.filler?.toLowerCase() === fillerHex.toLowerCase())
22+
return match?.user_op || undefined
23+
} catch (err) {
24+
logger.warn({ err, commitment }, "intents_getBidsForOrder failed for bid enrichment")
25+
return undefined
26+
}
27+
}
28+
29+
/**
30+
* Resolves the raw bid payload behind a BidPlaced event, or undefined if neither source has it.
31+
*
32+
* The extrinsic is tried first because it is part of the block being indexed: it is exact (the very
33+
* bid that raised this event), always present on replay, and costs no network call. The RPC is only
34+
* a backstop for the case where the call cannot be decoded.
35+
*/
36+
export async function resolveBidData(params: {
37+
extrinsic?: SubstrateExtrinsic
38+
commitment: string
39+
fillerHex: string
40+
nodeUrl?: string
41+
}): Promise<string | undefined> {
42+
const { extrinsic, commitment, fillerHex, nodeUrl } = params
43+
44+
const fromExtrinsic = extractUserOpFromExtrinsic(extrinsic, commitment)
45+
if (fromExtrinsic) return fromExtrinsic
46+
47+
if (!nodeUrl) return undefined
48+
return fetchBidDataFromRpc(nodeUrl, commitment, fillerHex)
49+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { SubstrateExtrinsic } from "@subql/types"
2+
3+
/**
4+
* Finds a call within an extrinsic's call tree and returns one of its arguments.
5+
*
6+
* Dispatches routinely arrive wrapped — utility.batch, proxy.proxy, sudo — so the call that raised
7+
* an event is often not the extrinsic's top-level method. `match` picks the call of interest (it
8+
* receives the call's decoded args, so it can discriminate between sibling calls of the same kind),
9+
* and its return value is what comes back.
10+
*
11+
* Kept free of SDK imports so it stays cheap to unit test.
12+
*/
13+
export function findInCallTree<T>(
14+
extrinsic: SubstrateExtrinsic | undefined,
15+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
16+
match: (call: { section: string; method: string; args: any[] }) => T | undefined,
17+
): T | undefined {
18+
if (!extrinsic) return undefined
19+
20+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
21+
const visit = (call: any, depth: number): T | undefined => {
22+
// Wrapped calls nest at most a few levels; the bound just stops a malformed or cyclic tree
23+
// from looping forever.
24+
if (!call || depth > 6) return undefined
25+
26+
const args = call.args
27+
if (!Array.isArray(args)) return undefined
28+
29+
if (typeof call.section === "string" && typeof call.method === "string") {
30+
const matched = match(call)
31+
if (matched !== undefined) return matched
32+
}
33+
34+
for (const arg of args) {
35+
// A nested call arg carries section/method itself; batch-style args carry an array of them.
36+
if (Array.isArray(arg)) {
37+
for (const inner of arg) {
38+
const found = visit(inner, depth + 1)
39+
if (found !== undefined) return found
40+
}
41+
} else if (arg?.section && arg?.method) {
42+
const found = visit(arg, depth + 1)
43+
if (found !== undefined) return found
44+
}
45+
}
46+
47+
return undefined
48+
}
49+
50+
try {
51+
return visit(extrinsic.extrinsic.method, 0)
52+
} catch {
53+
return undefined
54+
}
55+
}
56+
57+
/**
58+
* Pulls the `user_op` argument out of the place_bid call that raised a BidPlaced event.
59+
*
60+
* Matches on commitment, not just on the call being a place_bid: one batch may carry bids for
61+
* several orders, and taking the first would attribute another order's quote to this event.
62+
*/
63+
export function extractUserOpFromExtrinsic(
64+
extrinsic: SubstrateExtrinsic | undefined,
65+
commitment: string,
66+
): string | undefined {
67+
return findInCallTree(extrinsic, (call) => {
68+
if (call.section !== "intentsCoprocessor" || call.method !== "placeBid") return undefined
69+
const [commitmentArg, userOpArg] = call.args
70+
if (commitmentArg?.toHex?.() !== commitment) return undefined
71+
return userOpArg?.toHex?.() as string | undefined
72+
})
73+
}

0 commit comments

Comments
 (0)