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 @@ -120,7 +120,6 @@ Protocol fees are held until the order settles. `DustCollected` reports only the
```typescript lineNumbers title="setup.ts" icon="typescript"
import {
EvmChain,
IntentsCoprocessor,
IntentGateway,
IsmpClient,
SubstrateChain,
Expand All @@ -144,9 +143,9 @@ const hyperbridge = await SubstrateChain.connect({
hasher: "Keccak",
})

const coprocessor = await IntentsCoprocessor.connect("wss://nexus.rpc.polytope.technology")
// Bids are read from the HyperFX orderbook; pass an IntentsCoprocessor as the third argument to read Hyperbridge's as well.
const queryClient = createQueryClient({ url: "https://nexus.indexer.polytope.technology" })
const intentGateway = (await IntentGateway.create(sourceChain, destChain, coprocessor)).withQueryClient(queryClient)
const intentGateway = (await IntentGateway.create(sourceChain, destChain)).withQueryClient(queryClient)
const indexerClient = new IsmpClient({ queryClient, source: sourceChain, dest: destChain, hyperbridge })

const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY")
Expand Down Expand Up @@ -199,7 +198,7 @@ async function cancelExpiredSameChain(order: Order) {
) {
throw new Error("Gateway and keeper wallet must target the order's same chain")
}
const sameChainGateway = await IntentGateway.create(sourceChain, sourceChain, coprocessor)
const sameChainGateway = await IntentGateway.create(sourceChain, sourceChain)
const sameChainIndexer = new IsmpClient({
queryClient,
source: sourceChain,
Expand Down
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
12 changes: 6 additions & 6 deletions docs/content/developers/evm/intent-gateway/placing-orders.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,12 @@ An order placed without a graffiti (the SDK defaults to a zero `bytes32`) is una

Keep the `EvmChain` instances, the gateway, and the wallet client — every later step uses them.

The gateway reads solver bids from the HyperFX orderbook. Pass an `IntentsCoprocessor` as the third argument of `IntentGateway.create` to read the bids solvers post to Hyperbridge as well; the two are merged.

<Tabs items={['Same-chain', 'Cross-chain']}>
<Tab value="Same-chain">
```typescript title="same-chain-setup.ts" lineNumbers
import { EvmChain, IntentGateway, IntentsCoprocessor, createQueryClient } from "@hyperbridge/sdk"
import { EvmChain, IntentGateway, createQueryClient } from "@hyperbridge/sdk"
import { createWalletClient, http } from "viem"
import { privateKeyToAccount } from "viem/accounts"

Expand All @@ -154,17 +156,16 @@ const chain = await EvmChain.create(RPC_URL)
const sourceChain = chain
const destChain = chain

const coprocessor = await IntentsCoprocessor.connect("wss://nexus.rpc.polytope.technology")
const queryClient = createQueryClient({ url: "https://nexus.indexer.polytope.technology" })
const gateway = (await IntentGateway.create(sourceChain, destChain, coprocessor)).withQueryClient(queryClient)
const gateway = (await IntentGateway.create(sourceChain, destChain)).withQueryClient(queryClient)

const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY")
const walletClient = createWalletClient({ account, transport: http(RPC_URL) })
```
</Tab>
<Tab value="Cross-chain">
```typescript title="cross-chain-setup.ts" lineNumbers
import { EvmChain, IntentGateway, IntentsCoprocessor, createQueryClient } from "@hyperbridge/sdk"
import { EvmChain, IntentGateway, createQueryClient } from "@hyperbridge/sdk"
import { createWalletClient, http } from "viem"
import { privateKeyToAccount } from "viem/accounts"

Expand All @@ -176,9 +177,8 @@ const DEST_RPC_URL = "https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY"
const sourceChain = await EvmChain.create(SOURCE_RPC_URL)
const destChain = await EvmChain.create(DEST_RPC_URL)

const coprocessor = await IntentsCoprocessor.connect("wss://nexus.rpc.polytope.technology")
const queryClient = createQueryClient({ url: "https://nexus.indexer.polytope.technology" })
const gateway = (await IntentGateway.create(sourceChain, destChain, coprocessor)).withQueryClient(queryClient)
const gateway = (await IntentGateway.create(sourceChain, destChain)).withQueryClient(queryClient)

const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY")
const walletClient = createWalletClient({ account, transport: http(SOURCE_RPC_URL) })
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
5 changes: 4 additions & 1 deletion sdk/packages/sdk/src/chains/intentsCoprocessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,13 +476,16 @@ export class IntentsCoprocessor {
// The nonce this submission goes out under, and the one every later attempt replaces at.
let nonce: number | undefined = initialNonce
let stalled: SubmissionOutcome | undefined
/** What the last attempt said, so a caller can tell a fee refusal from a bad nonce. */
let lastError: string | undefined

while (attempt < maxRetries) {
const currentTip = BASE_TIP * BigInt(2 ** attempt) // Double tip on each retry
attempt++

try {
const result = await this.sendWithTimeout(extrinsic, keyPair, currentTip, timeoutMs, nonce)
if (result.error) lastError = result.error
// Reached the pool under this nonce: the next submission must clear it, whether it
// lands, stalls there, or bounced off a copy of itself already pooled.
if (nonce !== undefined && (result.success || result.pending || result.stalled)) {
Expand Down Expand Up @@ -521,7 +524,7 @@ export class IntentsCoprocessor {
return (
stalled ?? {
success: false,
error: `Transaction failed after ${maxRetries} attempts`,
error: `Transaction failed after ${maxRetries} attempts${lastError ? `: ${lastError}` : ""}`,
}
)
}
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
14 changes: 9 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,9 @@ export class IntentGateway {
*
* @param source - Source chain client.
* @param dest - Destination chain client.
* @param intentsCoprocessor - Optional coprocessor for bid fetching.
* @param intentsCoprocessor - Optional. Bids are read from the HyperFX orderbook; with a coprocessor
* they are read from Hyperbridge as well and merged. When omitted, bids are read from the
* orderbook alone.
*/
private constructor(
source: IEvmChain,
Expand Down Expand Up @@ -183,7 +185,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 +208,9 @@ 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. Bids are read from the HyperFX orderbook; with a coprocessor
* they are read from Hyperbridge as well and merged. When omitted, bids are read from the
* orderbook alone.
* @param options - Optional settings; see {@link IntentGatewayOptions}
* @returns Initialized IntentGateway instance
*/
Expand Down Expand Up @@ -248,8 +252,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
77 changes: 62 additions & 15 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,23 +157,53 @@ 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 the op's userOpHash, which is the op itself whatever identifier
* a solver filed it under on Hyperbridge; the Hyperbridge copy wins 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. An orderbook bid whose op does not
* decode is left out on its own, not with the rest of the orderbook's.
*
* 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
solver?: { address: HexString; timeoutMs: number }
solverLockStartTime: number
/** The userOpHash of a bid's op, as `executeOrder` keys bids. */
userOpHashKey: (userOp: FillerBid["userOp"]) => string
}): Promise<FillerBid[]> {
const { commitment, solver, solverLockStartTime } = params
const { commitment, solver, solverLockStartTime, userOpHashKey } = 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.flatMap(fillerBidsOf)))

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 = userOpHashKey(bid.userOp)
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 +328,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 @@ -437,7 +469,7 @@ export class OrderExecutor {
const auctionSeenBids = new Set<string>()
while (Date.now() < auctionEnd) {
try {
const bids = await this.fetchBids({ commitment, solver, solverLockStartTime })
const bids = await this.fetchBids({ commitment, solver, solverLockStartTime, userOpHashKey })
const newBids = bids.filter(
(bid) => isFreshBid(bid) && !auctionSeenBids.has(userOpHashKey(bid.userOp)),
)
Expand Down Expand Up @@ -467,7 +499,7 @@ export class OrderExecutor {
))
freshBids = final
? []
: (await this.fetchBids({ commitment, solver, solverLockStartTime })).filter(isFreshBid)
: (await this.fetchBids({ commitment, solver, solverLockStartTime, userOpHashKey })).filter(isFreshBid)
} catch {
await sleep(pollIntervalMs)
continue
Expand Down Expand Up @@ -548,3 +580,18 @@ 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, or nothing when its op
* does not decode. The orderbook holds no deposit and does not know the solver's Substrate account,
* so `filler` is the solver's address.
*/
function fillerBidsOf(bid: OrderbookBid): FillerBid[] {
try {
const userOp = decodeUserOpScale(bid.userOp)
return [{ filler: bid.solver, bid: CryptoUtils.bidId(userOp.callData), userOp, deposit: 0n, venue: "orderbook" }]
} catch (err) {
console.warn(`[OrderExecutor] Skipping an orderbook bid from ${bid.solver} whose op does not decode:`, err)
return []
}
}
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
Loading
Loading