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
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ dataSources:
filter:
module: bandwidth
method: TierSet
- handler: handleBidPlaced
kind: substrate/EventHandler
filter:
module: intentsCoprocessor
method: BidPlaced
- handler: handleBridgeTokenSupplyIndexing
kind: substrate/BlockHandler
filter:
Expand Down
49 changes: 49 additions & 0 deletions sdk/packages/indexer/src/configs/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -2238,6 +2238,55 @@ type PhantomOrder @entity {
blockTimestamp: Date @index
}

"""
A bid placed by a filler on an order commitment, recorded from the intents-coprocessor pallet's
BidPlaced event.

The bid payload is read out of the place_bid extrinsic in the same block, falling back to the node's
offchain storage via intents_getBidsForOrder — that RPC pins the chain head and the offchain entry
expires, so it only answers for bids still live on the queried node.

Fillers may re-bid on the same commitment (the pallet unreserves the previous deposit and emits a
fresh BidPlaced), so one row is written per event rather than per (commitment, filler) — the bid
history is preserved. The commitment usually belongs to a PhantomOrder, but place_bid accepts any
commitment, so the field is a plain reference rather than a foreign key.
"""
type FillerBid @entity {
"""
Composite identifier: {commitment}-{filler}-{blockNumber}-{eventIdx}. Unique per BidPlaced event,
so a filler's repeat bids on the same commitment are kept as separate rows.
"""
id: ID!

"""
The order commitment bid on — keccak256(abi.encode(Order)). Matches PhantomOrder.id when the bid
targets a phantom order.
"""
commitment: String! @index

"""
SS58 address of the filler that placed the bid.
"""
filler: String! @index

"""
The raw bid payload — the SCALE-encoded PackedUserOperation the filler submitted, as hex. Stored
undecoded so a bid stays fully re-interpretable as the fill ABI evolves, and because the pallet's
own copy expires. Null when neither the extrinsic nor the RPC yielded it.
"""
bidData: String

"""
Hash of the extrinsic that placed the bid, when the event is attributable to one.
"""
extrinsicHash: String

"""
Hyperbridge block number the bid was placed at.
"""
blockNumber: BigInt! @index
}

"""
The price snapshot for a phantom order, written once its bid window closes (signalled by the
PhantomBidWindowExhausted event). It collects all live bids via intents_getBidsForOrder and keeps
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { SubstrateEvent } from "@subql/types"

import { wrap } from "@/utils/event.utils"
import { replaceWebsocketWithHttp } from "@/utils/rpc.helpers"
import { getHostStateMachine } from "@/utils/substrate.helpers"
import { resolveBidData } from "@/utils/bid-data"
import { ENV_CONFIG } from "@/constants"
import { FillerBid } from "@/configs/src/types"

/**
* `pallet-intents-coprocessor :: BidPlaced` — a filler placed a bid on an order commitment.
*
* Payload order:
* 0. filler: AccountId
* 1. commitment: H256
* 2. deposit: Balance
*
* The event carries no bid payload, so it is resolved separately by resolveBidData and stored raw.
*/
export const handleBidPlaced = wrap(async (event: SubstrateEvent): Promise<void> => {
const {
event: { data },
block,
extrinsic,
} = event

const [fillerData, commitmentData] = data

const filler = fillerData.toString()
const commitment = commitmentData.toHex()
const blockNumber = block.block.header.number.toBigInt()

// A filler may re-bid on the same commitment, so the block number and event index are part of the
// key — keyed on (commitment, filler) alone, a repeat bid would overwrite its predecessor.
const id = `${commitment}-${filler}-${blockNumber}-${event.idx}`
if (await FillerBid.get(id)) return

const host = getHostStateMachine(chainId)

const bidData = await resolveBidData({
extrinsic,
commitment,
// The RPC keys bids by the raw AccountId bytes, not the SS58 form stored on the entity.
fillerHex: fillerData.toHex(),
nodeUrl: replaceWebsocketWithHttp(ENV_CONFIG[host] ?? "") || undefined,
})

await FillerBid.create({
id,
commitment,
filler,
bidData,
extrinsicHash: extrinsic?.extrinsic.hash.toString(),
blockNumber,
}).save()

logger.info({ commitment, filler, blockNumber }, `FillerBid indexed${bidData ? "" : " (no bid data)"}`)
})
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 @@ -23,6 +23,7 @@ export { handleDustSweptEventV3 } from "@/handlers/events/intentGatewayV3/dustSw
export { handleIsmpStateMachineUpdatedEvent } from "@/handlers/events/substrateChains/handleIsmpStateMachineUpdatedEvent.handler"
export { handlePhantomOrderRegistered } from "@/handlers/events/substrateChains/handlePhantomOrderRegistered.handler"
export { handlePhantomOrderPrices } from "@/handlers/events/substrateChains/handlePhantomOrderPrices.handler"
export { handleBidPlaced } from "@/handlers/events/substrateChains/handleBidPlaced.handler"
export { handleSubstratePostRequestTimeoutHandledEvent } from "@/handlers/events/substrateChains/handlePostRequestTimeoutHandledEvent.handler"
export { handleSubstrateRequestEvent } from "@/handlers/events/substrateChains/handleRequestEvent.handler"
export { handleSubstrateResponseEvent } from "@/handlers/events/substrateChains/handleResponseEvent.handler"
Expand Down
78 changes: 78 additions & 0 deletions sdk/packages/indexer/src/utils/__tests__/extrinsic.helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { SubstrateExtrinsic } from "@subql/types"
import { extractUserOpFromExtrinsic } from "@/utils/extrinsic.helpers"

// The user operation is read out of the place_bid extrinsic rather than intents_getBidsForOrder,
// because that RPC pins the chain head internally and the pallet's offchain copy expires — so on any
// backfill the extrinsic is the only source that still has the bid. place_bid can arrive wrapped in
// batch/proxy/sudo, so the walk has to find it at depth, and has to match on commitment: one batch
// may carry bids for several orders, and picking the wrong one would attribute another order's quote
// to this event.

const COMMITMENT = `0x${"11".repeat(32)}`
const OTHER_COMMITMENT = `0x${"22".repeat(32)}`
const USER_OP = "0xdeadbeef"
const OTHER_USER_OP = "0xfeedface"

const hex = (value: string) => ({ toHex: () => value })

const placeBid = (commitment: string, userOp: string) => ({
section: "intentsCoprocessor",
method: "placeBid",
args: [hex(commitment), hex(userOp)],
})

const batch = (calls: unknown[]) => ({
section: "utility",
method: "batchAll",
args: [calls],
})

const proxy = (call: unknown) => ({
section: "proxy",
method: "proxy",
args: [hex("0xaaaa"), hex("0x00"), call],
})

const asExtrinsic = (method: unknown) => ({ extrinsic: { method } }) as unknown as SubstrateExtrinsic


describe("extractUserOpFromExtrinsic", () => {
it("reads the user op from a direct place_bid call", () => {
expect(extractUserOpFromExtrinsic(asExtrinsic(placeBid(COMMITMENT, USER_OP)), COMMITMENT)).toBe(USER_OP)
})

it("finds a place_bid nested in a batch", () => {
const call = batch([placeBid(OTHER_COMMITMENT, OTHER_USER_OP), placeBid(COMMITMENT, USER_OP)])
expect(extractUserOpFromExtrinsic(asExtrinsic(call), COMMITMENT)).toBe(USER_OP)
})

it("finds a place_bid nested in a proxied batch", () => {
const call = proxy(batch([placeBid(COMMITMENT, USER_OP)]))
expect(extractUserOpFromExtrinsic(asExtrinsic(call), COMMITMENT)).toBe(USER_OP)
})

it("returns the user op of the matching commitment, not the first bid in the batch", () => {
const call = batch([placeBid(OTHER_COMMITMENT, OTHER_USER_OP), placeBid(COMMITMENT, USER_OP)])
expect(extractUserOpFromExtrinsic(asExtrinsic(call), OTHER_COMMITMENT)).toBe(OTHER_USER_OP)
})

it("returns undefined when no place_bid matches the commitment", () => {
const call = batch([placeBid(OTHER_COMMITMENT, OTHER_USER_OP)])
expect(extractUserOpFromExtrinsic(asExtrinsic(call), COMMITMENT)).toBeUndefined()
})

it("returns undefined for an unrelated extrinsic", () => {
const call = { section: "balances", method: "transfer", args: [hex("0xaaaa"), hex("0x01")] }
expect(extractUserOpFromExtrinsic(asExtrinsic(call), COMMITMENT)).toBeUndefined()
})

it("returns undefined when the event has no extrinsic", () => {
expect(extractUserOpFromExtrinsic(undefined, COMMITMENT)).toBeUndefined()
})

it("does not loop on a self-referential call tree", () => {
const cyclic: any = { section: "utility", method: "batchAll", args: [] }
cyclic.args = [[cyclic]]
expect(extractUserOpFromExtrinsic(asExtrinsic(cyclic), COMMITMENT)).toBeUndefined()
})
})
49 changes: 49 additions & 0 deletions sdk/packages/indexer/src/utils/bid-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { SubstrateExtrinsic } from "@subql/types"
import { fetchBidsForOrder, setAggregationFetch } from "@hyperbridge/sdk/intents-helpers"

import { safeFetch } from "@/utils/safeFetch"
import { extractUserOpFromExtrinsic } from "@/utils/extrinsic.helpers"

// The SDK's RPC helpers run inside the SubQuery VM2 sandbox, which has no global `fetch`.
setAggregationFetch(safeFetch)

/**
* Falls back to the node's offchain storage for a bid's payload.
*
* intents_getBidsForOrder pins the chain head internally and has no `at` parameter, and the
* underlying offchain entry is node-local and expires, so this only returns anything while the bid
* is still live on the node being queried. It is a best-effort backstop for the extrinsic path, not
* a replacement for it.
*/
async function fetchBidDataFromRpc(nodeUrl: string, commitment: string, fillerHex: string): Promise<string | undefined> {
try {
const bids = await fetchBidsForOrder(nodeUrl, commitment)
const match = bids.find((bid) => bid.filler?.toLowerCase() === fillerHex.toLowerCase())
return match?.user_op || undefined
} catch (err) {
logger.warn({ err, commitment }, "intents_getBidsForOrder failed for bid enrichment")
return undefined
}
}

/**
* Resolves the raw bid payload behind a BidPlaced event, or undefined if neither source has it.
*
* The extrinsic is tried first because it is part of the block being indexed: it is exact (the very
* bid that raised this event), always present on replay, and costs no network call. The RPC is only
* a backstop for the case where the call cannot be decoded.
*/
export async function resolveBidData(params: {
extrinsic?: SubstrateExtrinsic
commitment: string
fillerHex: string
nodeUrl?: string
}): Promise<string | undefined> {
const { extrinsic, commitment, fillerHex, nodeUrl } = params

const fromExtrinsic = extractUserOpFromExtrinsic(extrinsic, commitment)
if (fromExtrinsic) return fromExtrinsic

if (!nodeUrl) return undefined
return fetchBidDataFromRpc(nodeUrl, commitment, fillerHex)
}
73 changes: 73 additions & 0 deletions sdk/packages/indexer/src/utils/extrinsic.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { SubstrateExtrinsic } from "@subql/types"

/**
* Finds a call within an extrinsic's call tree and returns one of its arguments.
*
* Dispatches routinely arrive wrapped — utility.batch, proxy.proxy, sudo — so the call that raised
* an event is often not the extrinsic's top-level method. `match` picks the call of interest (it
* receives the call's decoded args, so it can discriminate between sibling calls of the same kind),
* and its return value is what comes back.
*
* Kept free of SDK imports so it stays cheap to unit test.
*/
export function findInCallTree<T>(
extrinsic: SubstrateExtrinsic | undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
match: (call: { section: string; method: string; args: any[] }) => T | undefined,
): T | undefined {
if (!extrinsic) return undefined

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const visit = (call: any, depth: number): T | undefined => {
// Wrapped calls nest at most a few levels; the bound just stops a malformed or cyclic tree
// from looping forever.
if (!call || depth > 6) return undefined

const args = call.args
if (!Array.isArray(args)) return undefined

if (typeof call.section === "string" && typeof call.method === "string") {
const matched = match(call)
if (matched !== undefined) return matched
}

for (const arg of args) {
// A nested call arg carries section/method itself; batch-style args carry an array of them.
if (Array.isArray(arg)) {
for (const inner of arg) {
const found = visit(inner, depth + 1)
if (found !== undefined) return found
}
} else if (arg?.section && arg?.method) {
const found = visit(arg, depth + 1)
if (found !== undefined) return found
}
}

return undefined
}

try {
return visit(extrinsic.extrinsic.method, 0)
} catch {
return undefined
}
}

/**
* Pulls the `user_op` argument out of the place_bid call that raised a BidPlaced event.
*
* Matches on commitment, not just on the call being a place_bid: one batch may carry bids for
* several orders, and taking the first would attribute another order's quote to this event.
*/
export function extractUserOpFromExtrinsic(
extrinsic: SubstrateExtrinsic | undefined,
commitment: string,
): string | undefined {
return findInCallTree(extrinsic, (call) => {
if (call.section !== "intentsCoprocessor" || call.method !== "placeBid") return undefined
const [commitmentArg, userOpArg] = call.args
if (commitmentArg?.toHex?.() !== commitment) return undefined
return userOpArg?.toHex?.() as string | undefined
})
}
Loading
Loading