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}`)