diff --git a/docs/content/developers/intent-gateway/placing-orders.mdx b/docs/content/developers/intent-gateway/placing-orders.mdx
index 3068e2fdb..f8d63a224 100644
--- a/docs/content/developers/intent-gateway/placing-orders.mdx
+++ b/docs/content/developers/intent-gateway/placing-orders.mdx
@@ -72,7 +72,7 @@ const walletClient = createWalletClient({
```
-The `bundlerUrl` on the destination chain is **required** for cross-chain orders. `IntentGateway` uses it to submit the solver's `UserOperation` to the ERC-4337 bundler on the destination chain. Without it, solver selection will fail at the `BID_SELECTED` stage.
+The `bundlerUrl` on the destination chain is **required** for cross-chain orders. `IntentGateway` uses it to submit the solver's `UserOperation` to the ERC-4337 bundler on the destination chain. Without it, bid submission will fail at the `BID_SELECTED` stage.
### Same-Chain Orders
@@ -266,14 +266,10 @@ async function runOrder(order: Order) {
console.log(`${update.bidCount} bids received`)
break
case IntentOrderStatus.BID_SELECTED:
- // Best bid chosen; SDK is encoding the SelectSolver UserOperation
- console.log("Best bid selected, solver:", update.selectedSolver)
- break
- case IntentOrderStatus.USEROP_SUBMITTED:
- // UserOp submitted to the ERC-4337 bundler
+ // Best bid chosen and UserOp submitted to the ERC-4337 bundler
// Cross-chain: execute() exits here — Hyperbridge finalisation is async
// Same-chain: SDK waits for the fill tx, then moves to FILLED or PARTIAL_FILL
- console.log("UserOp submitted to bundler:", update.userOpHash)
+ console.log("Bid selected, solver:", update.selectedSolver, "tx:", update.transactionHash)
break
case IntentOrderStatus.PARTIAL_FILL: {
// Same-chain only: a solver partially filled the output
@@ -310,8 +306,7 @@ async function runOrder(order: Order) {
| `ORDER_PLACED` | `placeOrder` tx confirmed on-chain | `order` (finalized `Order`), `receipt` |
| `AWAITING_BIDS` | Polling coprocessor for bids | `commitment`, `totalFilledAssets`, `remainingAssets` |
| `BIDS_RECEIVED` | `minBids` collected or `bidTimeoutMs` elapsed | `commitment`, `bidCount`, `bids` |
-| `BID_SELECTED` | Best bid chosen | `commitment`, `selectedSolver`, `userOpHash` |
-| `USEROP_SUBMITTED` | UserOp sent to bundler | `commitment`, `userOpHash`, `transactionHash` |
+| `BID_SELECTED` | Best bid chosen and UserOp sent to bundler | `commitment`, `selectedSolver`, `userOpHash`, `userOp`, `transactionHash` |
| `PARTIAL_FILL` | Same-chain partial fill confirmed; loop restarts | `commitment`, `filledAssets`, `totalFilledAssets`, `remainingAssets` |
| `FILLED` | Order fully filled | `commitment`, `userOpHash`, `transactionHash` |
| `EXPIRED` | Deadline reached or no new bids available — terminal | `commitment`, `totalFilledAssets`, `remainingAssets`, `error` |
@@ -387,7 +382,7 @@ Set `destination` to the target chain's state machine ID. The contract verifies
Cross-chain fills are **all-or-nothing** — the solver must provide the full required amount for every output asset in a single transaction. Partial fills are not supported for cross-chain orders. On fill, the contract dispatches a `RedeemEscrow` POST request via Hyperbridge back to the source chain, which releases escrowed tokens to the solver on receipt.
-`execute()` exits after `USEROP_SUBMITTED` for cross-chain orders — it does not wait for Hyperbridge finalisation. To track cross-chain settlement, monitor the `EscrowReleased` event on the source chain or use the indexer.
+`execute()` exits after `BID_SELECTED` for cross-chain orders — it does not wait for Hyperbridge finalisation. To track cross-chain settlement, monitor the `EscrowReleased` event on the source chain or use the indexer.
```typescript lineNumbers title="cross-chain-order.ts" icon="typescript"
import { toHex, parseUnits } from "viem"
diff --git a/docs/content/developers/sdk/api/intent-gateway.mdx b/docs/content/developers/sdk/api/intent-gateway.mdx
index 92f9c56bd..3f1894156 100644
--- a/docs/content/developers/sdk/api/intent-gateway.mdx
+++ b/docs/content/developers/sdk/api/intent-gateway.mdx
@@ -341,8 +341,7 @@ type IntentOrderStatusUpdate =
| { status: "ORDER_PLACED"; order: Order; receipt: TransactionReceipt }
| { status: "AWAITING_BIDS"; commitment: HexString; totalFilledAssets: TokenInfo[]; remainingAssets: TokenInfo[] }
| { status: "BIDS_RECEIVED"; commitment: HexString; bidCount: number; bids: FillerBid[] }
- | { status: "BID_SELECTED"; commitment: HexString; selectedSolver: HexString; userOpHash: HexString; userOp: PackedUserOperation }
- | { status: "USEROP_SUBMITTED"; commitment: HexString; userOpHash: HexString; selectedSolver: HexString; transactionHash?: HexString }
+ | { status: "BID_SELECTED"; commitment: HexString; selectedSolver: HexString; userOpHash: HexString; userOp: PackedUserOperation; transactionHash?: HexString }
| { status: "FILLED"; commitment: HexString; userOpHash: HexString; selectedSolver: HexString; transactionHash?: HexString; totalFilledAssets: TokenInfo[]; remainingAssets: TokenInfo[] }
| { status: "PARTIAL_FILL"; commitment: HexString; userOpHash: HexString; selectedSolver: HexString; transactionHash?: HexString; filledAssets: TokenInfo[]; totalFilledAssets: TokenInfo[]; remainingAssets: TokenInfo[] }
| { status: "EXPIRED"; commitment: HexString; totalFilledAssets?: TokenInfo[]; remainingAssets?: TokenInfo[]; error: string }
@@ -355,8 +354,7 @@ type IntentOrderStatusUpdate =
| `ORDER_PLACED` | `order`, `receipt` | Order confirmed on-chain; `receipt` is the full viem `TransactionReceipt` of the placement transaction |
| `AWAITING_BIDS` | `commitment`, `totalFilledAssets`, `remainingAssets` | Polling the coprocessor for solver bids |
| `BIDS_RECEIVED` | `commitment`, `bidCount`, `bids` | One or more bids collected |
-| `BID_SELECTED` | `commitment`, `selectedSolver`, `userOpHash`, `userOp` | Best bid selected and UserOperation submitted to the bundler |
-| `USEROP_SUBMITTED` | `commitment`, `userOpHash`, `selectedSolver`, `transactionHash?` | UserOperation sent to the bundler |
+| `BID_SELECTED` | `commitment`, `selectedSolver`, `userOpHash`, `userOp`, `transactionHash?` | Best bid selected and UserOperation submitted to the bundler |
| `FILLED` | `commitment`, `userOpHash`, `selectedSolver`, `transactionHash?`, `totalFilledAssets`, `remainingAssets` | Order fully filled on the destination chain |
| `PARTIAL_FILL` | `commitment`, `userOpHash`, `selectedSolver`, `transactionHash?`, `filledAssets`, `totalFilledAssets`, `remainingAssets` | Order partially filled; more fills may follow |
| `EXPIRED` | `commitment`, `totalFilledAssets?`, `remainingAssets?`, `error` | Order deadline reached or no new bids available — terminal |
diff --git a/sdk/packages/sdk/package.json b/sdk/packages/sdk/package.json
index 35d8e0705..7321e67ae 100644
--- a/sdk/packages/sdk/package.json
+++ b/sdk/packages/sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@hyperbridge/sdk",
- "version": "1.8.8",
+ "version": "1.9.0",
"description": "The hyperclient SDK provides utilities for querying proofs and statuses for cross-chain requests from HyperBridge.",
"type": "module",
"types": "./dist/node/index.d.ts",
diff --git a/sdk/packages/sdk/src/protocols/intents/IntentGateway.ts b/sdk/packages/sdk/src/protocols/intents/IntentGateway.ts
index d12978aca..75bd15d35 100644
--- a/sdk/packages/sdk/src/protocols/intents/IntentGateway.ts
+++ b/sdk/packages/sdk/src/protocols/intents/IntentGateway.ts
@@ -11,6 +11,7 @@ import type {
SelectBidResult,
FillerBid,
} from "@/types"
+import type { ResumeIntentOrderOptions } from "@/types"
import type { IEvmChain } from "@/chain"
import type { IntentsCoprocessor } from "@/chains/intentsCoprocessor"
import type { IndexerClient } from "@/client"
@@ -24,7 +25,7 @@ import { BidManager } from "./BidManager"
import { GasEstimator } from "./GasEstimator"
import { OrderStatusChecker } from "./OrderStatusChecker"
import type { ERC7821Call } from "@/types"
-import { DEFAULT_GRAFFITI } from "@/utils"
+import { DEFAULT_GRAFFITI, ADDRESS_ZERO } from "@/utils"
/**
* High-level facade for the IntentGatewayV2 protocol.
@@ -185,7 +186,7 @@ export class IntentGateway {
* The caller must sign the transaction and pass it back via `gen.next(signedTx)`.
* 3. Yields `ORDER_PLACED` with the finalised order and transaction hash once
* the `OrderPlaced` event is confirmed.
- * 4. Delegates to {@link OrderExecutor.executeIntentOrder} and forwards all
+ * 4. Delegates to {@link OrderExecutor.executeOrder} and forwards all
* subsequent status updates until the order is filled, exhausted, or fails.
*
* @param order - The order to place and execute. `order.fees` may be 0; it
@@ -196,7 +197,6 @@ export class IntentGateway {
* - `maxPriorityFeePerGasBumpPercent` — bump % for the priority fee estimate (default 8).
* - `maxFeePerGasBumpPercent` — bump % for the max fee estimate (default 10).
* - `minBids` — minimum bids to collect before selecting (default 1).
- * - `bidTimeoutMs` — how long to poll for bids before giving up (default 60 000 ms).
* - `pollIntervalMs` — interval between bid-polling attempts.
* @yields {@link IntentOrderStatusUpdate} at each lifecycle stage.
* @throws If the `placeOrder` generator behaves unexpectedly, or if gas
@@ -209,7 +209,6 @@ export class IntentGateway {
maxPriorityFeePerGasBumpPercent?: number
maxFeePerGasBumpPercent?: number
minBids?: number
- bidTimeoutMs?: number
pollIntervalMs?: number
solver?: { address: HexString; timeoutMs: number }
},
@@ -252,11 +251,10 @@ export class IntentGateway {
yield { status: "ORDER_PLACED", order: finalizedOrder, receipt: placementReceipt }
- for await (const status of this.orderExecutor.executeIntentOrder({
+ for await (const status of this.orderExecutor.executeOrder({
order: finalizedOrder,
sessionPrivateKey,
minBids: options?.minBids,
- bidTimeoutMs: options?.bidTimeoutMs,
pollIntervalMs: options?.pollIntervalMs,
solver: options?.solver,
})) {
@@ -266,6 +264,58 @@ export class IntentGateway {
return
}
+ /**
+ * Validates that an order has the minimum fields required for post-placement
+ * resume (i.e. it was previously placed and has an on-chain identity).
+ *
+ * @throws If `order.id` or `order.session` is missing or zero-valued.
+ */
+ private assertOrderCanResume(order: Order): void {
+ if (!order.id) {
+ throw new Error("Cannot resume execution without order.id")
+ }
+ if (!order.session || order.session === ADDRESS_ZERO) {
+ throw new Error("Cannot resume execution without order.session")
+ }
+ }
+
+ /**
+ * Resumes execution of a previously placed order.
+ *
+ * Use this method after an app restart or crash to pick up where
+ * {@link execute} left off. The order must already be placed on-chain
+ * (i.e. it must have a valid `id` and `session`).
+ *
+ * Internally delegates to {@link OrderExecutor.executeOrder} and
+ * yields the same status updates as the execution phase of {@link execute}:
+ * `AWAITING_BIDS`, `BIDS_RECEIVED`, `BID_SELECTED`,
+ * `FILLED`, `PARTIAL_FILL`, `EXPIRED`, or `FAILED`.
+ *
+ * Callers may check {@link isOrderFilled} or {@link isOrderRefunded} before
+ * calling this method to avoid resuming an already-terminal order.
+ *
+ * @param order - A previously placed order with a valid `id` and `session`.
+ * @param options - Optional tuning parameters for bid collection and execution.
+ * @yields {@link IntentOrderStatusUpdate} at each execution stage.
+ * @throws If the order is missing required fields for resumption.
+ */
+ async *resume(
+ order: Order,
+ options?: ResumeIntentOrderOptions,
+ ): AsyncGenerator {
+ this.assertOrderCanResume(order)
+
+ for await (const status of this.orderExecutor.executeOrder({
+ order,
+ sessionPrivateKey: options?.sessionPrivateKey,
+ minBids: options?.minBids,
+ pollIntervalMs: options?.pollIntervalMs,
+ solver: options?.solver,
+ })) {
+ yield status
+ }
+ }
+
/**
* Returns both the native token cost and the relayer fee for cancelling an
* order. Use `relayerFee` to approve the ERC-20 spend before submitting.
diff --git a/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts b/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts
index c20ab710f..93eb72882 100644
--- a/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts
+++ b/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts
@@ -1,38 +1,31 @@
-import type { HexString } from "@/types"
+import type { HexString, TokenInfo, Order } from "@/types"
import type { IntentOrderStatusUpdate, ExecuteIntentOrderOptions, FillerBid, SelectBidResult } from "@/types"
import { sleep, DEFAULT_POLL_INTERVAL, hexToString } from "@/utils"
import type { IntentGatewayContext } from "./types"
import { BidManager } from "./BidManager"
+// @ts-ignore
+import mergeRace from "@async-generator/merge-race"
-/**
- * Returns the storage key used to persist the deduplication set of already-
- * submitted UserOperation hashes for a given order commitment.
- *
- * @param commitment - The order commitment hash (bytes32).
- * @returns A namespaced string key safe to use with the `usedUserOpsStorage` adapter.
- */
const USED_USEROPS_STORAGE_KEY = (commitment: HexString) => `used-userops:${commitment.toLowerCase()}`
/**
- * Drives the post-placement execution lifecycle of an IntentGatewayV2 order.
+ * 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 and validates the best
- * bid, submits the corresponding ERC-4337 UserOperation via the bundler, and
- * tracks partial fills until the order is fully satisfied or exhausted.
+ * Hyperbridge coprocessor 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.
+ *
+ * Execution is structured as two racing async generators combined via
+ * `mergeRace`: an `executionStream` that polls for bids and submits
+ * UserOperations, and a `deadlineStream` that sleeps until the order's
+ * block deadline and yields `EXPIRED`. Whichever yields first wins.
*
* Deduplication of UserOperations is persisted across restarts using
* `usedUserOpsStorage` so that the executor can resume safely after a crash.
*/
export class OrderExecutor {
- /**
- * @param ctx - Shared IntentsV2 context providing the destination chain
- * client, coprocessor, bundler URL, and storage adapters.
- * @param bidManager - Handles bid validation, sorting, simulation, and
- * UserOperation submission.
- * @param crypto - Crypto utilities used to compute UserOperation hashes for
- * deduplication.
- */
constructor(
private readonly ctx: IntentGatewayContext,
private readonly bidManager: BidManager,
@@ -40,54 +33,36 @@ export class OrderExecutor {
) {}
/**
- * Async generator that executes an intent order by polling for bids and
- * submitting UserOperations until the order is filled, partially exhausted,
- * or an unrecoverable error occurs.
- *
- * **Status progression (cross-chain orders):**
- * `AWAITING_BIDS` → `BIDS_RECEIVED` → `BID_SELECTED` → `USEROP_SUBMITTED`
- * then terminates (settlement is confirmed off-chain via Hyperbridge).
- *
- * **Status progression (same-chain orders):**
- * `AWAITING_BIDS` → `BIDS_RECEIVED` → `BID_SELECTED` → `USEROP_SUBMITTED`
- * → (`FILLED` | `PARTIAL_FILL`)* → (`FILLED` | `EXPIRED`)
- *
- * **Error statuses:** `FAILED` (retryable error during bid selection/submission,
- * triggers automatic retry) or `EXPIRED` (deadline reached or no new bids —
- * terminal, no further retries).
- *
- * @param options - Execution parameters including the placed order, its
- * session private key, bid collection settings, and poll interval.
- * @yields {@link IntentOrderStatusUpdate} objects describing each stage.
- * @throws Never throws directly; all errors are reported as `FAILED` yields.
+ * Sleeps until the order's block deadline is reached, then yields EXPIRED.
+ * Uses the chain's block time to calculate the sleep duration.
*/
- async *executeIntentOrder(options: ExecuteIntentOrderOptions): AsyncGenerator {
- const {
- order,
- sessionPrivateKey,
- minBids = 1,
- bidTimeoutMs = 60_000,
- pollIntervalMs = DEFAULT_POLL_INTERVAL,
- solver,
- } = options
-
- const commitment = order.id as HexString
- const isSameChain = order.source === order.destination
-
- if (!this.ctx.intentsCoprocessor) {
- yield { status: "FAILED", error: "IntentsCoprocessor required for order execution" }
- return
+ private async *deadlineStream(
+ deadline: bigint,
+ commitment: HexString,
+ ): AsyncGenerator {
+ const client = this.ctx.dest.client
+ const blockTimeSeconds = client.chain?.blockTime ?? 2
+
+ while (true) {
+ const currentBlock = await client.getBlockNumber()
+ if (currentBlock >= deadline) break
+
+ const blocksRemaining = Number(deadline - currentBlock)
+ const sleepMs = blocksRemaining * blockTimeSeconds * 1_000
+ await sleep(sleepMs)
}
- if (!this.ctx.bundlerUrl) {
- yield { status: "FAILED", error: "Bundler URL not configured" }
- return
+ yield {
+ status: "EXPIRED",
+ commitment,
+ error: "Order deadline reached",
}
+ }
- // Load or initialize persistent dedup set for this commitment from storage
+ /** Loads the persisted deduplication set of already-submitted UserOp hashes for a given order commitment. */
+ private async loadUsedUserOps(commitment: HexString): Promise> {
const usedUserOps = new Set()
- const storageKey = USED_USEROPS_STORAGE_KEY(commitment)
- const persisted = await this.ctx.usedUserOpsStorage.getItem(storageKey)
+ const persisted = await this.ctx.usedUserOpsStorage.getItem(USED_USEROPS_STORAGE_KEY(commitment))
if (persisted) {
try {
const parsed = JSON.parse(persisted) as string[]
@@ -98,100 +73,287 @@ export class OrderExecutor {
// Ignore corrupt entries and start fresh
}
}
+ return usedUserOps
+ }
- const persistUsedUserOps = async () => {
- await this.ctx.usedUserOpsStorage.setItem(storageKey, JSON.stringify([...usedUserOps]))
- }
+ /** Persists the deduplication set of UserOp hashes to storage. */
+ private async persistUsedUserOps(commitment: HexString, usedUserOps: Set): Promise {
+ await this.ctx.usedUserOpsStorage.setItem(
+ USED_USEROPS_STORAGE_KEY(commitment),
+ JSON.stringify([...usedUserOps]),
+ )
+ }
- // Precompute UserOp hashing context for this order
+ /**
+ * Creates a closure that computes the deduplication hash key for a
+ * UserOperation, pre-bound to the order's destination chain and entry point.
+ */
+ private createUserOpHasher(order: {
+ destination: HexString
+ }): (userOp: SelectBidResult["userOp"] | FillerBid["userOp"]) => string {
const entryPointAddress = this.ctx.dest.configService.getEntryPointV08Address(hexToString(order.destination))
const chainId = BigInt(
this.ctx.dest.client.chain?.id ?? Number.parseInt(this.ctx.dest.config.stateMachineId.split("-")[1]),
)
+ return (userOp) => this.crypto.computeUserOpHash(userOp, entryPointAddress, chainId)
+ }
- const userOpHashKey = (userOp: SelectBidResult["userOp"] | FillerBid["userOp"]): string =>
- this.crypto.computeUserOpHash(userOp, entryPointAddress, chainId)
+ /**
+ * 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.
+ */
+ private async fetchBids(params: {
+ commitment: HexString
+ solver?: { address: HexString; timeoutMs: number }
+ solverLockStartTime: number
+ }): Promise {
+ const { commitment, solver, solverLockStartTime } = params
+
+ const fetchedBids = await this.ctx.intentsCoprocessor!.getBidsForOrder(commitment)
+
+ if (solver) {
+ const { address, timeoutMs } = solver
+ const solverLockActive = Date.now() - solverLockStartTime < timeoutMs
+
+ return solverLockActive
+ ? fetchedBids.filter((bid) => bid.userOp.sender.toLowerCase() === address.toLowerCase())
+ : fetchedBids
+ }
- // For partial fill tracking, initialise per-token accumulators from order.output.assets
- const targetAssets = order.output.assets.map((a) => ({ token: a.token, amount: a.amount }))
+ return fetchedBids
+ }
+
+ /**
+ * Selects the best bid from the provided candidates, submits the
+ * UserOperation, and persists the dedup entry to prevent resubmission.
+ */
+ private async submitBid(params: {
+ order: Order
+ freshBids: FillerBid[]
+ sessionPrivateKey?: HexString
+ commitment: HexString
+ usedUserOps: Set
+ userOpHashKey: (userOp: SelectBidResult["userOp"] | FillerBid["userOp"]) => string
+ }): Promise {
+ const { order, freshBids, sessionPrivateKey, commitment, usedUserOps, userOpHashKey } = params
+
+ const result = await this.bidManager.selectBid(order, freshBids, sessionPrivateKey)
+
+ usedUserOps.add(userOpHashKey(result.userOp))
+ await this.persistUsedUserOps(commitment, usedUserOps)
+
+ return result
+ }
+
+ /**
+ * Processes a fill result and returns updated fill accumulators,
+ * the status update to yield (if any), and whether the order is
+ * fully satisfied.
+ */
+ private processFillResult(
+ result: SelectBidResult,
+ commitment: HexString,
+ targetAssets: TokenInfo[],
+ totalFilledAssets: TokenInfo[],
+ remainingAssets: TokenInfo[],
+ ): {
+ update: IntentOrderStatusUpdate | null
+ done: boolean
+ totalFilledAssets: TokenInfo[]
+ remainingAssets: TokenInfo[]
+ } {
+ if (result.fillStatus === "full") {
+ totalFilledAssets = targetAssets.map((a) => ({ token: a.token, amount: a.amount }))
+ remainingAssets = targetAssets.map((a) => ({ token: a.token, amount: 0n }))
+
+ return {
+ update: {
+ status: "FILLED",
+ commitment,
+ userOpHash: result.userOpHash,
+ selectedSolver: result.solverAddress,
+ transactionHash: result.txnHash,
+ totalFilledAssets,
+ remainingAssets,
+ },
+ done: true,
+ totalFilledAssets,
+ remainingAssets,
+ }
+ }
+
+ if (result.fillStatus === "partial") {
+ const filledAssets = result.filledAssets ?? []
+
+ totalFilledAssets = totalFilledAssets.map((a) => {
+ const filled = filledAssets.find((f) => f.token === a.token)
+ return filled ? { token: a.token, amount: a.amount + filled.amount } : { ...a }
+ })
+
+ remainingAssets = targetAssets.map((target) => {
+ const filled = totalFilledAssets.find((a) => a.token === target.token)
+ const filledAmt = filled?.amount ?? 0n
+ return {
+ token: target.token,
+ amount: filledAmt >= target.amount ? 0n : target.amount - filledAmt,
+ }
+ })
+ const fullyFilled = remainingAssets.every((a) => a.amount === 0n)
+
+ return {
+ update: fullyFilled
+ ? {
+ status: "FILLED",
+ commitment,
+ userOpHash: result.userOpHash,
+ selectedSolver: result.solverAddress,
+ transactionHash: result.txnHash,
+ totalFilledAssets,
+ remainingAssets,
+ }
+ : {
+ status: "PARTIAL_FILL",
+ commitment,
+ userOpHash: result.userOpHash,
+ selectedSolver: result.solverAddress,
+ transactionHash: result.txnHash,
+ filledAssets,
+ totalFilledAssets,
+ remainingAssets,
+ },
+ done: fullyFilled,
+ totalFilledAssets,
+ remainingAssets,
+ }
+ }
+
+ return { update: null, done: false, totalFilledAssets, remainingAssets }
+ }
+
+ /**
+ * Executes an intent order by racing bid polling against the order's
+ * block deadline. Yields status updates at each lifecycle stage.
+ *
+ * **Same-chain:** `AWAITING_BIDS` → `BIDS_RECEIVED` → `BID_SELECTED`
+ * → (`FILLED` | `PARTIAL_FILL`)* → (`FILLED` | `EXPIRED`)
+ *
+ * **Cross-chain:** `AWAITING_BIDS` → `BIDS_RECEIVED` → `BID_SELECTED`
+ * (terminates — settlement is confirmed async via Hyperbridge)
+ */
+ async *executeOrder(options: ExecuteIntentOrderOptions): AsyncGenerator {
+ const { order, sessionPrivateKey, minBids = 1, pollIntervalMs = DEFAULT_POLL_INTERVAL, solver } = options
+
+ const commitment = order.id as HexString
+ const isSameChain = order.source === order.destination
+
+ 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
+ }
+
+ const usedUserOps = await this.loadUsedUserOps(commitment)
+ const userOpHashKey = this.createUserOpHasher(order)
+
+ const targetAssets = order.output.assets.map((a) => ({ token: a.token, amount: a.amount }))
let totalFilledAssets = order.output.assets.map((a) => ({ token: a.token, amount: 0n }))
let remainingAssets = order.output.assets.map((a) => ({ token: a.token, amount: a.amount }))
- try {
- while (true) {
- const currentBlock = await this.ctx.dest.client.getBlockNumber()
- if (currentBlock >= order.deadline) {
- const deadlineError = `Order deadline reached (block ${currentBlock} >= ${order.deadline})`
- yield {
- status: "EXPIRED",
- commitment,
- totalFilledAssets,
- remainingAssets,
- error: deadlineError,
- }
- return
- }
+ const executionStream = this.executionStream({
+ order,
+ sessionPrivateKey,
+ commitment,
+ minBids,
+ pollIntervalMs,
+ solver,
+ usedUserOps,
+ userOpHashKey,
+ targetAssets,
+ totalFilledAssets,
+ remainingAssets,
+ })
- yield { status: "AWAITING_BIDS", commitment, totalFilledAssets, remainingAssets }
+ const deadlineTimeout = this.deadlineStream(order.deadline, commitment)
+ const combined = mergeRace(deadlineTimeout, executionStream)
- const startTime = Date.now()
- let bids: FillerBid[] = []
- let solverLockExpired = false
+ for await (const update of combined) {
+ yield update
- while (Date.now() - startTime < bidTimeoutMs) {
- try {
- const fetchedBids = await this.ctx.intentsCoprocessor!.getBidsForOrder(commitment)
+ if (update.status === "EXPIRED" || update.status === "FILLED") return
- if (solver) {
- const { address, timeoutMs } = solver
- const solverLockActive = Date.now() - startTime < timeoutMs
- if (!solverLockActive) solverLockExpired = true
+ // Cross-chain orders terminate after submission
+ if (update.status === "BID_SELECTED" && !isSameChain) return
+ }
+ }
- bids = solverLockActive
- ? fetchedBids.filter((bid) => bid.userOp.sender.toLowerCase() === address.toLowerCase())
- : fetchedBids
- } else {
- bids = fetchedBids
- }
+ /**
+ * Core execution loop that polls for bids, submits UserOperations,
+ * and tracks fill progress. Yields between each poll iteration so
+ * that `mergeRace` can interleave the deadline stream.
+ */
+ private async *executionStream(params: {
+ order: Order
+ sessionPrivateKey?: HexString
+ commitment: HexString
+ minBids: number
+ pollIntervalMs: number
+ solver?: { address: HexString; timeoutMs: number }
+ usedUserOps: Set
+ userOpHashKey: (userOp: SelectBidResult["userOp"] | FillerBid["userOp"]) => string
+ targetAssets: TokenInfo[]
+ totalFilledAssets: TokenInfo[]
+ remainingAssets: TokenInfo[]
+ }): AsyncGenerator {
+ const {
+ order,
+ sessionPrivateKey,
+ commitment,
+ minBids,
+ pollIntervalMs,
+ solver,
+ usedUserOps,
+ userOpHashKey,
+ targetAssets,
+ } = params
+ let { totalFilledAssets, remainingAssets } = params
- if (bids.length >= minBids) {
- break
- }
- } catch {
- // Continue polling on errors
- }
+ const solverLockStartTime = Date.now()
+ yield { status: "AWAITING_BIDS", commitment, totalFilledAssets, remainingAssets }
+ try {
+ while (true) {
+ let freshBids: FillerBid[]
+ try {
+ const bids = await this.fetchBids({ commitment, solver, solverLockStartTime })
+ freshBids = bids.filter((bid) => !usedUserOps.has(userOpHashKey(bid.userOp)))
+ } catch {
await sleep(pollIntervalMs)
+ continue
}
- const freshBids = bids.filter((bid) => {
- const key = userOpHashKey(bid.userOp)
- return !usedUserOps.has(key)
- })
-
- if (freshBids.length === 0) {
- const solverClause = solver && !solverLockExpired ? ` for requested solver ${solver.address}` : ""
- const isPartiallyFilled = totalFilledAssets.some((a) => a.amount > 0n)
- const noBidsError = isPartiallyFilled
- ? `No new bids${solverClause} after partial fill`
- : `No new bids${solverClause} available within ${bidTimeoutMs}ms timeout`
-
- yield {
- status: "EXPIRED",
- commitment,
- totalFilledAssets,
- remainingAssets,
- error: noBidsError,
- }
- return
+ if (freshBids.length < minBids) {
+ await sleep(pollIntervalMs)
+ continue
}
yield { status: "BIDS_RECEIVED", commitment, bidCount: freshBids.length, bids: freshBids }
- let result: SelectBidResult
+ let submitResult: SelectBidResult
try {
- result = await this.bidManager.selectBid(order, freshBids, sessionPrivateKey)
+ submitResult = await this.submitBid({
+ order,
+ freshBids,
+ sessionPrivateKey,
+ commitment,
+ usedUserOps,
+ userOpHashKey,
+ })
} catch (err) {
yield {
status: "FAILED",
@@ -200,95 +362,32 @@ export class OrderExecutor {
remainingAssets,
error: `Failed to select bid and submit: ${err instanceof Error ? err.message : String(err)}`,
}
- // Back off before retrying
await sleep(pollIntervalMs)
continue
}
- const usedKey = userOpHashKey(result.userOp)
- usedUserOps.add(usedKey)
- await persistUsedUserOps()
-
yield {
status: "BID_SELECTED",
commitment,
- selectedSolver: result.solverAddress,
- userOpHash: result.userOpHash,
- userOp: result.userOp,
+ selectedSolver: submitResult.solverAddress,
+ userOpHash: submitResult.userOpHash,
+ userOp: submitResult.userOp,
+ transactionHash: submitResult.txnHash,
}
- yield {
- status: "USEROP_SUBMITTED",
+ const fill = this.processFillResult(
+ submitResult,
commitment,
- userOpHash: result.userOpHash,
- selectedSolver: result.solverAddress,
- transactionHash: result.txnHash,
- }
-
- if (!isSameChain) {
- return
- }
-
- if (result.fillStatus === "full") {
- // On a full fill, treat the order as completely satisfied
- totalFilledAssets = targetAssets.map((a) => ({ token: a.token, amount: a.amount }))
- remainingAssets = targetAssets.map((a) => ({ token: a.token, amount: 0n }))
-
- yield {
- status: "FILLED",
- commitment,
- userOpHash: result.userOpHash,
- selectedSolver: result.solverAddress,
- transactionHash: result.txnHash,
- totalFilledAssets,
- remainingAssets,
- }
- return
- }
-
- if (result.fillStatus === "partial") {
- const filledAssets = result.filledAssets ?? []
-
- // Accumulate per-token filled amounts
- for (const filled of filledAssets) {
- const entry = totalFilledAssets.find((a) => a.token === filled.token)
- if (entry) entry.amount += filled.amount
- }
-
- // Recompute remaining per-token
- remainingAssets = targetAssets.map((target) => {
- const filled = totalFilledAssets.find((a) => a.token === target.token)
- const filledAmt = filled?.amount ?? 0n
- return {
- token: target.token,
- amount: filledAmt >= target.amount ? 0n : target.amount - filledAmt,
- }
- })
-
- const fullyFilled = remainingAssets.every((a) => a.amount === 0n)
- if (fullyFilled) {
- yield {
- status: "FILLED",
- commitment,
- userOpHash: result.userOpHash,
- selectedSolver: result.solverAddress,
- transactionHash: result.txnHash,
- totalFilledAssets,
- remainingAssets,
- }
- return
- }
-
- yield {
- status: "PARTIAL_FILL",
- commitment,
- userOpHash: result.userOpHash,
- selectedSolver: result.solverAddress,
- transactionHash: result.txnHash,
- filledAssets,
- totalFilledAssets,
- remainingAssets,
- }
+ targetAssets,
+ totalFilledAssets,
+ remainingAssets,
+ )
+ totalFilledAssets = fill.totalFilledAssets
+ remainingAssets = fill.remainingAssets
+
+ if (fill.update) {
+ yield fill.update
+ if (fill.done) return
}
}
} catch (err) {
diff --git a/sdk/packages/sdk/src/types/index.ts b/sdk/packages/sdk/src/types/index.ts
index 53161b479..2e39c2b8a 100644
--- a/sdk/packages/sdk/src/types/index.ts
+++ b/sdk/packages/sdk/src/types/index.ts
@@ -1318,7 +1318,6 @@ export const IntentOrderStatus = Object.freeze({
AWAITING_BIDS: "AWAITING_BIDS",
BIDS_RECEIVED: "BIDS_RECEIVED",
BID_SELECTED: "BID_SELECTED",
- USEROP_SUBMITTED: "USEROP_SUBMITTED",
FILLED: "FILLED",
PARTIAL_FILL: "PARTIAL_FILL",
EXPIRED: "EXPIRED",
@@ -1340,12 +1339,6 @@ export type IntentOrderStatusUpdate =
selectedSolver: HexString
userOpHash: HexString
userOp: PackedUserOperation
- }
- | {
- status: "USEROP_SUBMITTED"
- commitment: HexString
- userOpHash: HexString
- selectedSolver: HexString
transactionHash?: HexString
}
| {
@@ -1399,7 +1392,6 @@ export interface ExecuteIntentOrderOptions {
order: Order
sessionPrivateKey?: HexString
minBids?: number
- bidTimeoutMs?: number
pollIntervalMs?: number
/**
* If set, bids are restricted to the given solver until `timeoutMs` elapses,
@@ -1413,6 +1405,17 @@ export interface ExecuteIntentOrderOptions {
}
}
+/** Options for resuming execution of a previously placed intent order */
+export interface ResumeIntentOrderOptions {
+ sessionPrivateKey?: HexString
+ minBids?: number
+ pollIntervalMs?: number
+ solver?: {
+ address: HexString
+ timeoutMs: number
+ }
+}
+
/** Type for ERC-7821 Call struct */
export type ERC7821Call = {
target: `0x${string}`
diff --git a/sdk/packages/simplex/src/tests/strategies/basic.testnet.test.ts b/sdk/packages/simplex/src/tests/strategies/basic.testnet.test.ts
index a4770814a..33d1e52d2 100644
--- a/sdk/packages/simplex/src/tests/strategies/basic.testnet.test.ts
+++ b/sdk/packages/simplex/src/tests/strategies/basic.testnet.test.ts
@@ -147,9 +147,9 @@ describe("Filler V2 - Solver Selection ON", () => {
if (status.status === "BID_SELECTED") {
selectedSolver = status.selectedSolver as HexString
userOpHash = status.userOpHash as HexString
- }
- if (status.status === "USEROP_SUBMITTED" && status.transactionHash) {
- console.log("Transaction hash:", status.transactionHash)
+ if (status.transactionHash) {
+ console.log("Transaction hash:", status.transactionHash)
+ }
}
if (status.status === "FAILED") {
throw new Error(`Order execution failed: ${status.error}`)
@@ -362,9 +362,9 @@ describe.skip("Filler V2 - Tron Source Chain", () => {
if (status.status === "BID_SELECTED") {
selectedSolver = status.selectedSolver as HexString
userOpHash = status.userOpHash as HexString
- }
- if (status.status === "USEROP_SUBMITTED" && status.transactionHash) {
- console.log("Transaction hash:", status.transactionHash)
+ if (status.transactionHash) {
+ console.log("Transaction hash:", status.transactionHash)
+ }
}
if (status.status === "FAILED") {
throw new Error(`Order execution failed: ${status.error}`)
diff --git a/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts b/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts
index a90a980f8..eec3a6505 100644
--- a/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts
+++ b/sdk/packages/simplex/src/tests/strategies/fx.mainnet.test.ts
@@ -158,9 +158,9 @@ describe.skip("Filler V2 FX - Polygon mainnet same-chain swap", () => {
if (status.status === "BID_SELECTED") {
selectedSolver = status.selectedSolver as HexString
userOpHash = status.userOpHash as HexString
- }
- if (status.status === "USEROP_SUBMITTED" && status.transactionHash) {
- console.log("Transaction hash:", status.transactionHash)
+ if (status.transactionHash) {
+ console.log("Transaction hash:", status.transactionHash)
+ }
}
if (status.status === "FAILED") {
throw new Error(`Order execution failed: ${status.error}`)
@@ -287,9 +287,9 @@ describe.skip("Filler V2 FX - Base mainnet same-chain swap", () => {
if (status.status === "BID_SELECTED") {
selectedSolver = status.selectedSolver as HexString
userOpHash = status.userOpHash as HexString
- }
- if (status.status === "USEROP_SUBMITTED" && status.transactionHash) {
- console.log("Transaction hash:", status.transactionHash)
+ if (status.transactionHash) {
+ console.log("Transaction hash:", status.transactionHash)
+ }
}
if (status.status === "FAILED") {
throw new Error(`Order execution failed: ${status.error}`)
@@ -509,12 +509,6 @@ describe.skip("Filler V2 FX - Base mainnet same-chain swap", () => {
commitment: status.commitment,
selectedSolver: status.selectedSolver,
userOpHash: status.userOpHash,
- })
- break
- case "USEROP_SUBMITTED":
- tlog("USEROP_SUBMITTED", {
- commitment: status.commitment,
- userOpHash: status.userOpHash,
txHash: status.transactionHash,
})
break
@@ -909,9 +903,9 @@ describe.skip("Filler V2 FX - Base mainnet same-chain USDC→cNGN with V4 fundin
if (status.status === "BID_SELECTED") {
selectedSolver = status.selectedSolver as HexString
userOpHash = status.userOpHash as HexString
- }
- if (status.status === "USEROP_SUBMITTED" && status.transactionHash) {
- console.log("Transaction hash:", status.transactionHash)
+ if (status.transactionHash) {
+ console.log("Transaction hash:", status.transactionHash)
+ }
}
if (status.status === "FAILED") {
throw new Error(`Order execution failed: ${status.error}`)
@@ -1240,9 +1234,9 @@ describe.skip("Filler V2 FX - Base mainnet same-chain USDC→cNGN with V4 fundin
if (status.status === "BID_SELECTED") {
selectedSolver = status.selectedSolver as HexString
userOpHash = status.userOpHash as HexString
- }
- if (status.status === "USEROP_SUBMITTED" && status.transactionHash) {
- console.log("Transaction hash:", status.transactionHash)
+ if (status.transactionHash) {
+ console.log("Transaction hash:", status.transactionHash)
+ }
}
if (status.status === "FAILED") {
throw new Error(`Order execution failed: ${status.error}`)
@@ -1374,9 +1368,9 @@ describe.skip("Filler V2 FX - Arbitrum mainnet same-chain swap", () => {
if (status.status === "BID_SELECTED") {
selectedSolver = status.selectedSolver as HexString
userOpHash = status.userOpHash as HexString
- }
- if (status.status === "USEROP_SUBMITTED" && status.transactionHash) {
- console.log("Transaction hash:", status.transactionHash)
+ if (status.transactionHash) {
+ console.log("Transaction hash:", status.transactionHash)
+ }
}
if (status.status === "FAILED") {
throw new Error(`Order execution failed: ${status.error}`)
@@ -1521,9 +1515,9 @@ describe.skip("Filler V2 FX - Arbitrum to Base cross-chain swap", () => {
if (status.status === "BID_SELECTED") {
selectedSolver = status.selectedSolver as HexString
userOpHash = status.userOpHash as HexString
- }
- if (status.status === "USEROP_SUBMITTED" && status.transactionHash) {
- console.log("Transaction hash:", status.transactionHash)
+ if (status.transactionHash) {
+ console.log("Transaction hash:", status.transactionHash)
+ }
}
if (status.status === "FAILED") {
throw new Error(`Order execution failed: ${status.error}`)