Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
25 changes: 25 additions & 0 deletions docs/content/developers/evm/intent-gateway/orderbook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,31 @@ The orderbook prices an exact input on a route two ways. Both take the same argu

Both only take an input amount. To price an exact output, compute the input from `routeLiquidity.bestRate` and quote it. If the quote falls short, keep the fills better than its worst price, re-price the rest of the output at the worst fill's `amountIn` to `amountOut` ratio, and quote again. The SDK's `quoteIntent` does this for you, with either quote.

## Bids on an order

Solvers answer a swapper's order with signed fill UserOps. A solver posts each bid to Hyperbridge, which takes a BRIDGE deposit for it, and to the orderbook, which takes none; a solver holding no BRIDGE posts to the orderbook alone. `bids` serves the live bids on an order under its commitment, leg by leg and, within a leg, best for the swapper first. The orderbook accepts a bid only from a solver holding an active limit order at that price on the leg's book, and serves it for `serverInfo.bidTtlSecs`.

```graphql
{
bids(commitment: "0x6a6f…") {
solver { address }
leg
side
fillChain
sourceChain
tokenIn
tokenOut
price
amountIn
amountOut
expiresAt
userOp
}
}
```

`userOp` is the signed UserOp, SCALE-encoded as the solver submitted it: what the swapper executes. `price` is the rate the bid signed, quote per 1 base, before the protocol fee. The op's own `validUntil` is a block number on `fillChain`, which the gateway checks and the orderbook does not. The SDK's `IntentGateway.executeOrder` reads bids from Hyperbridge and from here, merged by bid identifier.

## Price history

Each validation cycle records the book's top of book, depth and liquidity, on each fill chain and across chains. Recorded history is kept for 30 days.
Expand Down
2 changes: 1 addition & 1 deletion sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"node": ">=22",
"pnpm": ">=11"
},
"packageManager": "pnpm@11.0.0",
"packageManager": "pnpm@11.28.7",
"dependencies": {
"jest": "^29.7.0",
"ts-node": "^10.9.2"
Expand Down
3 changes: 0 additions & 3 deletions sdk/packages/sdk/src/protocols/intents/BidManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,6 @@ export class BidManager {
if (!this.ctx.bundlerUrl) {
throw new Error("Bundler URL not configured")
}
if (!this.ctx.intentsCoprocessor) {
throw new Error("IntentsCoprocessor required")
}

const sortedBids = await this.sortBids(order, bids)
console.log(`[BidManager] ${sortedBids.length}/${bids.length} bid(s) passed validation and sorting`)
Expand Down
10 changes: 5 additions & 5 deletions sdk/packages/sdk/src/protocols/intents/IntentGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export class IntentGateway {
*
* @param source - Source chain client.
* @param dest - Destination chain client.
* @param intentsCoprocessor - Optional coprocessor for bid fetching.
* @param intentsCoprocessor - Optional coprocessor, read for bids beside the HyperFX orderbook.
*/
private constructor(
source: IEvmChain,
Expand Down Expand Up @@ -183,7 +183,7 @@ export class IntentGateway {
const gasEstimator = new GasEstimator(this.ctx, crypto)

this.orderPlacer = new OrderPlacer(this.ctx)
this.orderExecutor = new OrderExecutor(this.ctx, bidManager)
this.orderExecutor = new OrderExecutor(this.ctx, bidManager, () => this.orderbook)
this.orderCanceller = new OrderCanceller(this.ctx)
this.orderStatusChecker = new OrderStatusChecker(this.ctx)
this.bidManager = bidManager
Expand All @@ -206,7 +206,7 @@ export class IntentGateway {
*
* @param source - Source chain for order placement
* @param dest - Destination chain for order fulfillment
* @param intentsCoprocessor - Optional coprocessor for bid fetching and order execution
* @param intentsCoprocessor - Optional coprocessor, read for bids beside the HyperFX orderbook
Comment thread
seunlanlege marked this conversation as resolved.
Outdated
* @param options - Optional settings; see {@link IntentGatewayOptions}
* @returns Initialized IntentGateway instance
*/
Expand Down Expand Up @@ -248,8 +248,8 @@ export class IntentGateway {
}

/**
* Points quotes, liquidity and rate reads at a HyperFX orderbook other than
* the destination chain's network deployment ({@link ORDERBOOK_URLS}).
* Points quotes, liquidity and rate reads, and the bids read during execution, at a HyperFX
* orderbook other than the destination chain's network deployment ({@link ORDERBOOK_URLS}).
* Returns `this` for chaining.
*
* @param orderbook - The orderbook's GraphQL URL, or a configured client.
Expand Down
62 changes: 50 additions & 12 deletions sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { CryptoUtils } from "./CryptoUtils"
import type { IntentGatewayContext } from "./types"
import { ABI as IntentGatewayV2ABI } from "@/abis/IntentGatewayV2"
import { ENTRY_POINT_V08 } from "@/configs/chain"
import { decodeUserOpScale } from "@/chains/intentsCoprocessor"
import { readLegPartialFill } from "./escrowReads"
import type { HyperFxOrderbook, OrderbookBid } from "./orderbook/client"

const USED_USEROPS_STORAGE_KEY = (commitment: HexString) => `used-userops:${commitment.toLowerCase()}`

Expand All @@ -22,7 +24,7 @@ function remainingAfter(targets: TokenInfo[], filled: TokenInfo[]): TokenInfo[]
* Drives the post-placement execution lifecycle of an intent order.
*
* After an order is placed on the source chain, `OrderExecutor` polls the
* Hyperbridge coprocessor for solver bids, selects the best bid, submits
* Hyperbridge coprocessor and the HyperFX orderbook for solver bids, selects the best bid, submits
* the corresponding ERC-4337 UserOperation via the bundler, and tracks
* partial fills until the order is fully satisfied or its on-chain block
* deadline is reached. Cross-chain fills are confirmed from the destination
Expand All @@ -37,9 +39,14 @@ function remainingAfter(targets: TokenInfo[], filled: TokenInfo[]): TokenInfo[]
* `usedUserOpsStorage` so that the executor can resume safely after a crash.
*/
export class OrderExecutor {
/**
* @param orderbook - The HyperFX orderbook bids are read from beside the coprocessor. A getter,
* so the gateway can point it elsewhere after construction.
*/
constructor(
private readonly ctx: IntentGatewayContext,
private readonly bidManager: BidManager,
private readonly orderbook: () => HyperFxOrderbook,
) {}

/**
Expand Down Expand Up @@ -150,9 +157,17 @@ export class OrderExecutor {
}

/**
* Fetches bids from the coprocessor for a given order commitment.
* If a preferred solver is configured and the solver lock has not expired,
* only bids from that solver are returned.
* The bids on an order, from Hyperbridge and from the HyperFX orderbook together.
*
* A solver posts its bid to both when it holds BRIDGE for the Hyperbridge deposit and to the
* orderbook alone when it does not, so neither source is the whole picture. The two are read
* side by side and merged by solver and bid identifier (`keccak256` of the op's calldata, which
* is how Hyperbridge files a solver's bids), the Hyperbridge copy winning a tie since it
* carries the deposit. One source failing is not the read failing:
* its bids are left out this round and the other's are returned; only both failing throws.
*
* If a preferred solver is configured and the solver lock has not expired, only bids from
* that solver are returned.
*/
private async fetchBids(params: {
commitment: HexString
Expand All @@ -161,12 +176,31 @@ export class OrderExecutor {
}): Promise<FillerBid[]> {
const { commitment, solver, solverLockStartTime } = params

const reads: Promise<FillerBid[]>[] = []
const intentsCoprocessor = this.ctx.intentsCoprocessor
if (!intentsCoprocessor) {
throw new Error("IntentsCoprocessor required for order execution")
if (intentsCoprocessor) reads.push(intentsCoprocessor.getBidsForOrder(commitment))
reads.push(this.orderbook().bids(commitment).then((bids) => bids.map(fillerBidOf)))

const settled = await Promise.allSettled(reads)
const failures = settled.filter((result): result is PromiseRejectedResult => result.status === "rejected")
if (failures.length === settled.length) {
throw new AggregateError(
failures.map((failure) => failure.reason),
`Could not read the bids on ${commitment} from any source`,
)
}

const fetchedBids = await intentsCoprocessor.getBidsForOrder(commitment)
const merged = new Map<string, FillerBid>()
for (const result of settled) {
if (result.status !== "fulfilled") continue
for (const bid of result.value) {
const key = `${bid.userOp.sender.toLowerCase()}:${bid.bid.toLowerCase()}`
Comment thread
seunlanlege marked this conversation as resolved.
Outdated
const held = merged.get(key)
// Hyperbridge's copy carries the deposit; the orderbook's is the same op without it.
if (!held || (held.venue === "orderbook" && bid.venue !== "orderbook")) merged.set(key, bid)
}
}
const fetchedBids = [...merged.values()]

if (solver) {
const { address, timeoutMs } = solver
Expand Down Expand Up @@ -291,11 +325,6 @@ export class OrderExecutor {

const commitment = order.id as HexString

if (!this.ctx.intentsCoprocessor) {
yield { status: "FAILED", error: "IntentsCoprocessor required for order execution" }
return
}

if (!this.ctx.bundlerUrl) {
yield { status: "FAILED", error: "Bundler URL not configured" }
return
Expand Down Expand Up @@ -548,3 +577,12 @@ function servesOpenLeg(bid: Bid, remainingAssets: TokenInfo[]): boolean {
if (bid.outputs.length !== remainingAssets.length) return true
return bid.outputs.some((output, leg) => output.amount > 0n && remainingAssets[leg].amount > 0n)
}

/**
* An orderbook bid in the shape Hyperbridge's bids take, so the two merge. The orderbook holds no
* deposit and does not know the solver's Substrate account, so `filler` is the solver's address.
*/
function fillerBidOf(bid: OrderbookBid): FillerBid {
const userOp = decodeUserOpScale(bid.userOp)
return { filler: bid.solver, bid: CryptoUtils.bidId(userOp.callData), userOp, deposit: 0n, venue: "orderbook" }
}
1 change: 1 addition & 0 deletions sdk/packages/sdk/src/protocols/intents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type {
BuyAndSellRates,
IntentQuoteLeg,
PessimisticQuoteIntentResult,
OrderbookBid,
OrderbookBook,
OrderbookRate,
OrderbookRoute,
Expand Down
96 changes: 96 additions & 0 deletions sdk/packages/sdk/src/protocols/intents/orderbook/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { GraphQLClient } from "graphql-request"
import type { HexString } from "@/types"
import { TESTNET_CHAINS } from "@/utils"

/** The HyperFX orderbook deployments, one per network. */
Expand Down Expand Up @@ -131,6 +132,41 @@ export interface OrderbookLevelQuote {
maxFillableIn: bigint
}

/**
* A solver's bid on a swapper's order, as the orderbook serves it under the order's commitment.
* Amounts and the rate are at 1e18; `userOp` is the signed fill UserOp, SCALE-encoded as the
* solver submitted it, which is what the swapper executes.
*/
export interface OrderbookBid {
/** The solver account the bid is from. */
solver: HexString
/** The swapper's order this bid fills: `keccak256(abi.encode(order))`. */
commitment: HexString
/** Which of the order's legs the bid quotes, from 0. A solver fills each leg with a bid of its own. */
leg: number
book: OrderbookBook
/** BID where the solver buys the book's base, ASK where it sells it. */
side: OrderbookSide
/** The order's destination, where the bid executes and `tokenOut` is delivered. */
fillChain: string
/** The order's source, where the swapper's `tokenIn` is escrowed. */
sourceChain: string
tokenIn: string
tokenOut: string
/** The rate the bid signed, quote per 1 base, before the protocol fee. */
price: bigint
/** The input the bid takes of the leg, in `tokenIn`. */
amountIn: bigint
/** What the solver pays for that take, in `tokenOut`, before the protocol fee. */
amountOut: bigint
/** ISO-8601, when the orderbook received the bid. */
receivedAt: string
/** ISO-8601, when the orderbook stops serving it. The op's own `validUntil` is a block on `fillChain`. */
expiresAt: string
/** The signed UserOp, SCALE-encoded. */
userOp: HexString
}

/** The best bid and ask a route can reach, with the book that orients them. */
export interface OrderbookTopOfBook {
book: OrderbookBook
Expand Down Expand Up @@ -180,12 +216,22 @@ query QuotePessimistic($route: RouteInput!, $amountIn: BigInt!) {
}
}`

const BIDS_QUERY = `
query Bids($commitment: Bytes!, $leg: Int, $solver: Address) {
bids(commitment: $commitment, leg: $leg, solver: $solver) {
solver { address }
commitment leg book { id base quote } side fillChain sourceChain tokenIn tokenOut
price amountIn amountOut receivedAt expiresAt userOp
}
}`

/** Every document the client sends, so they can be checked against the orderbook's published schema. */
export const ORDERBOOK_QUERIES = {
topOfBook: TOP_OF_BOOK_QUERY,
routeLiquidity: ROUTE_LIQUIDITY_QUERY,
quote: QUOTE_QUERY,
quotePessimistic: QUOTE_PESSIMISTIC_QUERY,
bids: BIDS_QUERY,
} as const

/**
Expand Down Expand Up @@ -297,6 +343,38 @@ export class HyperFxOrderbook {
}
}

/**
* The live bids on the swapper's order under `commitment`, leg by leg and, within a leg,
* best for the swapper first. Every bid is from a solver holding an active limit order at
* its price on the orderbook; whether that order still stands on chain is the swapper's to
* check, and so is the op's own `validUntil`. `leg` keeps one leg's bids, `solver` one
* solver's.
*/
async bids(commitment: HexString, filter: { leg?: number; solver?: HexString } = {}): Promise<OrderbookBid[]> {
const { bids } = await this.request<{ bids: RawBid[] }>(BIDS_QUERY, {
commitment,
leg: filter.leg ?? null,
solver: filter.solver ?? null,
})
return bids.map((bid) => ({
solver: bid.solver.address,
commitment: bid.commitment,
leg: bid.leg,
book: bid.book,
side: bid.side,
fillChain: bid.fillChain,
sourceChain: bid.sourceChain,
tokenIn: bid.tokenIn,
tokenOut: bid.tokenOut,
price: BigInt(bid.price),
amountIn: BigInt(bid.amountIn),
amountOut: BigInt(bid.amountOut),
receivedAt: bid.receivedAt,
expiresAt: bid.expiresAt,
userOp: bid.userOp,
}))
}

private async request<T>(query: string, variables: Record<string, unknown>): Promise<T> {
try {
return await this.client.request<T>(query, variables)
Expand Down Expand Up @@ -379,6 +457,24 @@ interface RawSwapQuote {
fills: { advertisedSize: string; orderRate: string; amountIn: string; amountOut: string }[]
}

interface RawBid {
solver: { address: HexString }
commitment: HexString
leg: number
book: OrderbookBook
side: OrderbookSide
fillChain: string
sourceChain: string
tokenIn: string
tokenOut: string
price: string
amountIn: string
amountOut: string
receivedAt: string
expiresAt: string
userOp: HexString
}

interface RawLevelQuote {
route: OrderbookRouteKind
side: OrderbookSide
Expand Down
1 change: 1 addition & 0 deletions sdk/packages/sdk/src/protocols/intents/orderbook/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
orderbookUrlFor,
ORDERBOOK_DECIMALS,
ORDERBOOK_QUERIES,
type OrderbookBid,
type OrderbookBook,
type OrderbookRate,
type OrderbookRoute,
Expand Down
Loading
Loading