From 122b1c79753dc4b039a8cdc4ae1fd50a12fce3c4 Mon Sep 17 00:00:00 2001 From: David Salami Date: Wed, 21 Oct 2026 17:15:35 +0000 Subject: [PATCH 1/4] [simplex]: post bids to the HyperFX orderbook, and to it alone without BRIDGE; the SDK reads bids from both The orderbook now takes bids on swappers' orders (submitBid) and serves them under the order's commitment (bids). Simplex posts every bid there and to Hyperbridge only while the account holds BRIDGE for the deposit, read by a balance gate before each bid. Orderbook acceptance is recorded on the bid row (onOrderbook) so fills on orderbook-only bids settle against the limit order that priced them, while the retraction sweep keeps to rows with a Hyperbridge deposit. The SDK's OrderExecutor reads bids from the coprocessor and the orderbook together, merged by solver and bid id, and no longer requires a coprocessor to execute an order. The schema fixture is refreshed from hyperfx-orderbook main. --- .../evm/intent-gateway/orderbook.mdx | 25 ++ .../sdk/src/protocols/intents/BidManager.ts | 3 - .../src/protocols/intents/IntentGateway.ts | 10 +- .../src/protocols/intents/OrderExecutor.ts | 62 ++++- .../sdk/src/protocols/intents/index.ts | 1 + .../src/protocols/intents/orderbook/client.ts | 96 ++++++++ .../src/protocols/intents/orderbook/index.ts | 1 + .../sdk/src/tests/bidSelection.test.ts | 121 +++++++++- sdk/packages/sdk/src/types/index.ts | 13 +- .../2026-10-21-bids-on-the-orderbook.md | 67 ++++++ sdk/packages/simplex/src/core/bid-venues.ts | 209 +++++++++++++++++ sdk/packages/simplex/src/core/boot.ts | 14 +- sdk/packages/simplex/src/core/filler.ts | 82 +++++-- sdk/packages/simplex/src/data/memory.ts | 1 + sdk/packages/simplex/src/data/sqlite/bids.ts | 10 +- sdk/packages/simplex/src/data/types.ts | 9 + sdk/packages/simplex/src/orderbook/client.ts | 56 +++++ sdk/packages/simplex/src/orderbook/types.ts | 32 +++ .../simplex/src/services/server/dto.ts | 3 + sdk/packages/simplex/src/strategies/base.ts | 31 ++- sdk/packages/simplex/src/strategies/fx.ts | 60 +++-- .../simplex/src/tests/core/bid-venues.test.ts | 220 ++++++++++++++++++ .../src/tests/core/orderbook-bids.test.ts | 170 ++++++++++++++ .../tests/fixtures/orderbook-schema.graphql | 201 +++++++++++++--- .../simplex/ui/src/operator/Orders.tsx | 14 +- 25 files changed, 1394 insertions(+), 117 deletions(-) create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-10-21-bids-on-the-orderbook.md create mode 100644 sdk/packages/simplex/src/core/bid-venues.ts create mode 100644 sdk/packages/simplex/src/tests/core/bid-venues.test.ts create mode 100644 sdk/packages/simplex/src/tests/core/orderbook-bids.test.ts diff --git a/docs/content/developers/evm/intent-gateway/orderbook.mdx b/docs/content/developers/evm/intent-gateway/orderbook.mdx index 9950242187..1b968140ee 100644 --- a/docs/content/developers/evm/intent-gateway/orderbook.mdx +++ b/docs/content/developers/evm/intent-gateway/orderbook.mdx @@ -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. diff --git a/sdk/packages/sdk/src/protocols/intents/BidManager.ts b/sdk/packages/sdk/src/protocols/intents/BidManager.ts index feb4ca42aa..43619b7a0b 100644 --- a/sdk/packages/sdk/src/protocols/intents/BidManager.ts +++ b/sdk/packages/sdk/src/protocols/intents/BidManager.ts @@ -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`) diff --git a/sdk/packages/sdk/src/protocols/intents/IntentGateway.ts b/sdk/packages/sdk/src/protocols/intents/IntentGateway.ts index 9ae128d6a8..5c96354414 100644 --- a/sdk/packages/sdk/src/protocols/intents/IntentGateway.ts +++ b/sdk/packages/sdk/src/protocols/intents/IntentGateway.ts @@ -144,7 +144,7 @@ export class IntentGateway { * * @param source - Source chain client. * @param dest - Destination chain client. - * @param intentsCoprocessor - Optional coprocessor for bid fetching. + * @param intentsCoprocessor - Optional coprocessor, read for bids beside the HyperFX orderbook. */ private constructor( source: IEvmChain, @@ -183,7 +183,7 @@ export class IntentGateway { const gasEstimator = new GasEstimator(this.ctx, crypto) this.orderPlacer = new OrderPlacer(this.ctx) - this.orderExecutor = new OrderExecutor(this.ctx, bidManager) + this.orderExecutor = new OrderExecutor(this.ctx, bidManager, () => this.orderbook) this.orderCanceller = new OrderCanceller(this.ctx) this.orderStatusChecker = new OrderStatusChecker(this.ctx) this.bidManager = bidManager @@ -206,7 +206,7 @@ export class IntentGateway { * * @param source - Source chain for order placement * @param dest - Destination chain for order fulfillment - * @param intentsCoprocessor - Optional coprocessor for bid fetching and order execution + * @param intentsCoprocessor - Optional coprocessor, read for bids beside the HyperFX orderbook * @param options - Optional settings; see {@link IntentGatewayOptions} * @returns Initialized IntentGateway instance */ @@ -248,8 +248,8 @@ export class IntentGateway { } /** - * Points quotes, liquidity and rate reads at a HyperFX orderbook other than - * the destination chain's network deployment ({@link ORDERBOOK_URLS}). + * Points quotes, liquidity and rate reads, and the bids read during execution, at a HyperFX + * orderbook other than the destination chain's network deployment ({@link ORDERBOOK_URLS}). * Returns `this` for chaining. * * @param orderbook - The orderbook's GraphQL URL, or a configured client. diff --git a/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts b/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts index 2646a50069..442e1d13a3 100644 --- a/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts +++ b/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts @@ -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()}` @@ -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 @@ -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, ) {} /** @@ -150,9 +157,17 @@ export class OrderExecutor { } /** - * Fetches bids from the coprocessor for a given order commitment. - * If a preferred solver is configured and the solver lock has not expired, - * only bids from that solver are returned. + * The bids on an order, from Hyperbridge and from the HyperFX orderbook together. + * + * A solver posts its bid to both when it holds BRIDGE for the Hyperbridge deposit and to the + * orderbook alone when it does not, so neither source is the whole picture. The two are read + * side by side and merged by solver and bid identifier (`keccak256` of the op's calldata, which + * is how Hyperbridge files a solver's bids), the Hyperbridge copy winning a tie since it + * carries the deposit. One source failing is not the read failing: + * its bids are left out this round and the other's are returned; only both failing throws. + * + * If a preferred solver is configured and the solver lock has not expired, only bids from + * that solver are returned. */ private async fetchBids(params: { commitment: HexString @@ -161,12 +176,31 @@ export class OrderExecutor { }): Promise { const { commitment, solver, solverLockStartTime } = params + const reads: Promise[] = [] const intentsCoprocessor = this.ctx.intentsCoprocessor - if (!intentsCoprocessor) { - throw new Error("IntentsCoprocessor required for order execution") + if (intentsCoprocessor) reads.push(intentsCoprocessor.getBidsForOrder(commitment)) + reads.push(this.orderbook().bids(commitment).then((bids) => bids.map(fillerBidOf))) + + const settled = await Promise.allSettled(reads) + const failures = settled.filter((result): result is PromiseRejectedResult => result.status === "rejected") + if (failures.length === settled.length) { + throw new AggregateError( + failures.map((failure) => failure.reason), + `Could not read the bids on ${commitment} from any source`, + ) } - const fetchedBids = await intentsCoprocessor.getBidsForOrder(commitment) + const merged = new Map() + for (const result of settled) { + if (result.status !== "fulfilled") continue + for (const bid of result.value) { + const key = `${bid.userOp.sender.toLowerCase()}:${bid.bid.toLowerCase()}` + 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 @@ -291,11 +325,6 @@ export class OrderExecutor { const commitment = order.id as HexString - if (!this.ctx.intentsCoprocessor) { - yield { status: "FAILED", error: "IntentsCoprocessor required for order execution" } - return - } - if (!this.ctx.bundlerUrl) { yield { status: "FAILED", error: "Bundler URL not configured" } return @@ -548,3 +577,12 @@ function servesOpenLeg(bid: Bid, remainingAssets: TokenInfo[]): boolean { if (bid.outputs.length !== remainingAssets.length) return true return bid.outputs.some((output, leg) => output.amount > 0n && remainingAssets[leg].amount > 0n) } + +/** + * An orderbook bid in the shape Hyperbridge's bids take, so the two merge. The orderbook holds no + * deposit and does not know the solver's Substrate account, so `filler` is the solver's address. + */ +function fillerBidOf(bid: OrderbookBid): FillerBid { + const userOp = decodeUserOpScale(bid.userOp) + return { filler: bid.solver, bid: CryptoUtils.bidId(userOp.callData), userOp, deposit: 0n, venue: "orderbook" } +} diff --git a/sdk/packages/sdk/src/protocols/intents/index.ts b/sdk/packages/sdk/src/protocols/intents/index.ts index b1a94f0f7a..15dd0ecdb5 100644 --- a/sdk/packages/sdk/src/protocols/intents/index.ts +++ b/sdk/packages/sdk/src/protocols/intents/index.ts @@ -16,6 +16,7 @@ export type { BuyAndSellRates, IntentQuoteLeg, PessimisticQuoteIntentResult, + OrderbookBid, OrderbookBook, OrderbookRate, OrderbookRoute, diff --git a/sdk/packages/sdk/src/protocols/intents/orderbook/client.ts b/sdk/packages/sdk/src/protocols/intents/orderbook/client.ts index 5930553c7a..fff044a842 100644 --- a/sdk/packages/sdk/src/protocols/intents/orderbook/client.ts +++ b/sdk/packages/sdk/src/protocols/intents/orderbook/client.ts @@ -1,4 +1,5 @@ import { GraphQLClient } from "graphql-request" +import type { HexString } from "@/types" import { TESTNET_CHAINS } from "@/utils" /** The HyperFX orderbook deployments, one per network. */ @@ -131,6 +132,41 @@ export interface OrderbookLevelQuote { maxFillableIn: bigint } +/** + * A solver's bid on a swapper's order, as the orderbook serves it under the order's commitment. + * Amounts and the rate are at 1e18; `userOp` is the signed fill UserOp, SCALE-encoded as the + * solver submitted it, which is what the swapper executes. + */ +export interface OrderbookBid { + /** The solver account the bid is from. */ + solver: HexString + /** The swapper's order this bid fills: `keccak256(abi.encode(order))`. */ + commitment: HexString + /** Which of the order's legs the bid quotes, from 0. A solver fills each leg with a bid of its own. */ + leg: number + book: OrderbookBook + /** BID where the solver buys the book's base, ASK where it sells it. */ + side: OrderbookSide + /** The order's destination, where the bid executes and `tokenOut` is delivered. */ + fillChain: string + /** The order's source, where the swapper's `tokenIn` is escrowed. */ + sourceChain: string + tokenIn: string + tokenOut: string + /** The rate the bid signed, quote per 1 base, before the protocol fee. */ + price: bigint + /** The input the bid takes of the leg, in `tokenIn`. */ + amountIn: bigint + /** What the solver pays for that take, in `tokenOut`, before the protocol fee. */ + amountOut: bigint + /** ISO-8601, when the orderbook received the bid. */ + receivedAt: string + /** ISO-8601, when the orderbook stops serving it. The op's own `validUntil` is a block on `fillChain`. */ + expiresAt: string + /** The signed UserOp, SCALE-encoded. */ + userOp: HexString +} + /** The best bid and ask a route can reach, with the book that orients them. */ export interface OrderbookTopOfBook { book: OrderbookBook @@ -180,12 +216,22 @@ query QuotePessimistic($route: RouteInput!, $amountIn: BigInt!) { } }` +const BIDS_QUERY = ` +query Bids($commitment: Bytes!, $leg: Int, $solver: Address) { + bids(commitment: $commitment, leg: $leg, solver: $solver) { + solver { address } + commitment leg book { id base quote } side fillChain sourceChain tokenIn tokenOut + price amountIn amountOut receivedAt expiresAt userOp + } +}` + /** Every document the client sends, so they can be checked against the orderbook's published schema. */ export const ORDERBOOK_QUERIES = { topOfBook: TOP_OF_BOOK_QUERY, routeLiquidity: ROUTE_LIQUIDITY_QUERY, quote: QUOTE_QUERY, quotePessimistic: QUOTE_PESSIMISTIC_QUERY, + bids: BIDS_QUERY, } as const /** @@ -297,6 +343,38 @@ export class HyperFxOrderbook { } } + /** + * The live bids on the swapper's order under `commitment`, leg by leg and, within a leg, + * best for the swapper first. Every bid is from a solver holding an active limit order at + * its price on the orderbook; whether that order still stands on chain is the swapper's to + * check, and so is the op's own `validUntil`. `leg` keeps one leg's bids, `solver` one + * solver's. + */ + async bids(commitment: HexString, filter: { leg?: number; solver?: HexString } = {}): Promise { + const { bids } = await this.request<{ bids: RawBid[] }>(BIDS_QUERY, { + commitment, + leg: filter.leg ?? null, + solver: filter.solver ?? null, + }) + return bids.map((bid) => ({ + solver: bid.solver.address, + commitment: bid.commitment, + leg: bid.leg, + book: bid.book, + side: bid.side, + fillChain: bid.fillChain, + sourceChain: bid.sourceChain, + tokenIn: bid.tokenIn, + tokenOut: bid.tokenOut, + price: BigInt(bid.price), + amountIn: BigInt(bid.amountIn), + amountOut: BigInt(bid.amountOut), + receivedAt: bid.receivedAt, + expiresAt: bid.expiresAt, + userOp: bid.userOp, + })) + } + private async request(query: string, variables: Record): Promise { try { return await this.client.request(query, variables) @@ -379,6 +457,24 @@ interface RawSwapQuote { fills: { advertisedSize: string; orderRate: string; amountIn: string; amountOut: string }[] } +interface RawBid { + solver: { address: HexString } + commitment: HexString + leg: number + book: OrderbookBook + side: OrderbookSide + fillChain: string + sourceChain: string + tokenIn: string + tokenOut: string + price: string + amountIn: string + amountOut: string + receivedAt: string + expiresAt: string + userOp: HexString +} + interface RawLevelQuote { route: OrderbookRouteKind side: OrderbookSide diff --git a/sdk/packages/sdk/src/protocols/intents/orderbook/index.ts b/sdk/packages/sdk/src/protocols/intents/orderbook/index.ts index 57743fd9c2..bdd73b3e74 100644 --- a/sdk/packages/sdk/src/protocols/intents/orderbook/index.ts +++ b/sdk/packages/sdk/src/protocols/intents/orderbook/index.ts @@ -5,6 +5,7 @@ export { orderbookUrlFor, ORDERBOOK_DECIMALS, ORDERBOOK_QUERIES, + type OrderbookBid, type OrderbookBook, type OrderbookRate, type OrderbookRoute, diff --git a/sdk/packages/sdk/src/tests/bidSelection.test.ts b/sdk/packages/sdk/src/tests/bidSelection.test.ts index 02716853ab..29564fe873 100644 --- a/sdk/packages/sdk/src/tests/bidSelection.test.ts +++ b/sdk/packages/sdk/src/tests/bidSelection.test.ts @@ -1,6 +1,8 @@ +import { encodeUserOpScale } from "@/chains/intentsCoprocessor" import { CryptoUtils } from "@/protocols/intents/CryptoUtils" import { BidManager } from "@/protocols/intents/BidManager" import { OrderExecutor } from "@/protocols/intents/OrderExecutor" +import type { HyperFxOrderbook, OrderbookBid } from "@/protocols/intents/orderbook/client" import type { Bid, FillerBid, HexString, Order, PackedUserOperation, SelectBidResult } from "@/types" import { describe, expect, it, vi } from "vitest" @@ -31,14 +33,14 @@ function pendingDeadline() { } function makeContext(params: { - getBidsForOrder: () => Promise + getBidsForOrder?: () => Promise readContract: ReturnType getBlockNumber: () => Promise setItem?: (key: string, value: string) => Promise }) { return { bundlerUrl: "http://bundler.test", - intentsCoprocessor: { getBidsForOrder: params.getBidsForOrder }, + intentsCoprocessor: params.getBidsForOrder ? { getBidsForOrder: params.getBidsForOrder } : undefined, dest: { config: { stateMachineId: "EVM-8453" }, configService: { getEntryPointAddress: () => ENTRY_POINT, getIntentGatewayAddress: () => GATEWAY }, @@ -55,6 +57,38 @@ function makeContext(params: { } as never } +/** An orderbook serving `bids` on every order, or failing every read when given an error. */ +function orderbookWith(bids: OrderbookBid[] | Error = []) { + return () => + ({ + bids: vi.fn(async () => { + if (bids instanceof Error) throw bids + return bids + }), + }) as unknown as HyperFxOrderbook +} + +/** `userOp` as the orderbook would serve it: the solver's signed op, SCALE-encoded. */ +function orderbookBid(userOp: PackedUserOperation): OrderbookBid { + return { + solver: userOp.sender as HexString, + commitment: COMMITMENT, + leg: 0, + book: { id: "USDC/CNGN", base: "USDC", quote: "CNGN" }, + side: "BID", + fillChain: "EVM-8453", + sourceChain: "EVM-8453", + tokenIn: "USDC", + tokenOut: "CNGN", + price: 10n ** 18n, + amountIn: 10n ** 18n, + amountOut: 10n ** 18n, + receivedAt: "2026-10-21T00:00:00Z", + expiresAt: "2026-10-21T00:10:00Z", + userOp: encodeUserOpScale(userOp), + } +} + function makeUserOp(sender = SOLVER_ONE): PackedUserOperation { return { sender, @@ -143,7 +177,7 @@ describe("Order execution bid-selection integration", () => { }) const bidManager = new BidManager(ctx, {} as never) vi.spyOn(bidManager, "buildBids").mockReturnValue([first, second]) - const executor = new OrderExecutor(ctx, bidManager) + const executor = new OrderExecutor(ctx, bidManager, orderbookWith()) const stream = executor.executeOrder({ order: makeOrder(), auctionTimeMs: 0, pollIntervalMs: 0 }) expect((await stream.next()).value).toMatchObject({ status: "AWAITING_BIDS", commitment: COMMITMENT }) @@ -175,7 +209,7 @@ describe("Order execution bid-selection integration", () => { readContract: gatewayReads({ credited: [40n] }), getBlockNumber: deadline.getBlockNumber, }) - const stream = new OrderExecutor(ctx, new BidManager(ctx, {} as never)).executeOrder({ + const stream = new OrderExecutor(ctx, new BidManager(ctx, {} as never), orderbookWith()).executeOrder({ order: makeOrder(), auctionTimeMs: 0, pollIntervalMs: 0, @@ -198,7 +232,7 @@ describe("Order execution bid-selection integration", () => { readContract, getBlockNumber: deadline.getBlockNumber, }) - const stream = new OrderExecutor(ctx, new BidManager(ctx, {} as never)).executeOrder({ + const stream = new OrderExecutor(ctx, new BidManager(ctx, {} as never), orderbookWith()).executeOrder({ order: makeOrder(), auctionTimeMs: 0, pollIntervalMs: 0, @@ -224,7 +258,7 @@ describe("Order execution bid-selection integration", () => { readContract: gatewayReads({ finalizer: SOLVER_ONE, credited: [40n] }), getBlockNumber: async () => 0n, }) - const stream = new OrderExecutor(ctx, new BidManager(ctx, {} as never)).executeOrder({ + const stream = new OrderExecutor(ctx, new BidManager(ctx, {} as never), orderbookWith()).executeOrder({ order: makeOrder(), auctionTimeMs: 0, pollIntervalMs: 0, @@ -243,7 +277,7 @@ describe("Order execution bid-selection integration", () => { const readContract = gatewayReads({}) readContract.mockRejectedValue(new Error("rpc down")) const ctx = makeContext({ getBidsForOrder: async () => [], readContract, getBlockNumber: async () => 0n }) - const stream = new OrderExecutor(ctx, new BidManager(ctx, {} as never)).executeOrder({ + const stream = new OrderExecutor(ctx, new BidManager(ctx, {} as never), orderbookWith()).executeOrder({ order: makeOrder(), auctionTimeMs: 0, pollIntervalMs: 0, @@ -321,7 +355,7 @@ describe("Order execution bid-selection integration", () => { vi.spyOn(bidManager, "buildBids").mockImplementation((_order, fillerBids) => fillerBids.map((fillerBid) => bySender.get(fillerBid.userOp.sender.toLowerCase())!), ) - const stream = new OrderExecutor(ctx, bidManager).executeOrder({ + const stream = new OrderExecutor(ctx, bidManager, orderbookWith()).executeOrder({ order: rateOrder(), auctionTimeMs: 0, pollIntervalMs: 0, @@ -434,7 +468,7 @@ describe("Order execution bid-selection integration", () => { vi.spyOn(bidManager, "buildBids").mockImplementation((_order, fillerBids) => fillerBids.map((fillerBid) => bySender.get(fillerBid.userOp.sender.toLowerCase())!), ) - const stream = new OrderExecutor(ctx, bidManager).executeOrder({ + const stream = new OrderExecutor(ctx, bidManager, orderbookWith()).executeOrder({ order: twoLegs, auctionTimeMs: 0, pollIntervalMs: 0, @@ -464,4 +498,73 @@ describe("Order execution bid-selection integration", () => { deadline.expire() await stream.return() }) + describe("bids from Hyperbridge and the orderbook", () => { + /** The merged bids, as `executionStream` reads them each round. */ + async function readBids(ctx: unknown, orderbook: () => HyperFxOrderbook): Promise { + const executor = new OrderExecutor(ctx as never, new BidManager(ctx as never, {} as never), orderbook) + const { fetchBids } = executor as unknown as { + fetchBids(params: { commitment: HexString; solverLockStartTime: number }): Promise + } + return fetchBids.call(executor, { commitment: COMMITMENT, solverLockStartTime: Date.now() }) + } + + function hyperbridgeBid(userOp: PackedUserOperation, deposit = 10n): FillerBid { + return { filler: "5Hyperbridge", bid: CryptoUtils.bidId(userOp.callData), userOp, deposit } + } + + it("reads both sources and merges a bid posted to both under its identifier, keeping the deposit", async () => { + const shared = { ...makeUserOp(SOLVER_ONE), callData: "0xaaaa" as HexString } + const orderbookOnly = { ...makeUserOp(SOLVER_TWO), callData: "0xbbbb" as HexString } + const ctx = makeContext({ + getBidsForOrder: async () => [hyperbridgeBid(shared)], + readContract: gatewayReads({}), + getBlockNumber: async () => 0n, + }) + + const bids = await readBids(ctx, orderbookWith([orderbookBid(shared), orderbookBid(orderbookOnly)])) + + expect(bids.map((bid) => bid.bid)).toEqual([ + CryptoUtils.bidId(shared.callData), + CryptoUtils.bidId(orderbookOnly.callData), + ]) + // The Hyperbridge copy wins the tie: same op, and it carries the deposit. + expect(bids[0]).toMatchObject({ filler: "5Hyperbridge", deposit: 10n }) + expect(bids[0].venue).toBeUndefined() + // The orderbook's own: decoded from the SCALE op it served, with the solver's address for want of a Substrate account. + expect(bids[1]).toMatchObject({ filler: SOLVER_TWO, deposit: 0n, venue: "orderbook" }) + expect(bids[1].userOp).toEqual(orderbookOnly) + }) + + it("reads the orderbook alone when no coprocessor is configured", async () => { + const op = makeUserOp(SOLVER_ONE) + const ctx = makeContext({ readContract: gatewayReads({}), getBlockNumber: async () => 0n }) + + const bids = await readBids(ctx, orderbookWith([orderbookBid(op)])) + + expect(bids).toHaveLength(1) + expect(bids[0].userOp).toEqual(op) + }) + + it("keeps one source's bids when the other cannot be read, and fails only when neither can", async () => { + const op = makeUserOp(SOLVER_ONE) + const ctx = makeContext({ + getBidsForOrder: async () => [hyperbridgeBid(op)], + readContract: gatewayReads({}), + getBlockNumber: async () => 0n, + }) + expect(await readBids(ctx, orderbookWith(new Error("orderbook down")))).toHaveLength(1) + + const failing = makeContext({ + getBidsForOrder: async () => { + throw new Error("rpc down") + }, + readContract: gatewayReads({}), + getBlockNumber: async () => 0n, + }) + expect(await readBids(failing, orderbookWith([orderbookBid(op)]))).toHaveLength(1) + await expect(readBids(failing, orderbookWith(new Error("orderbook down")))).rejects.toThrow( + "Could not read the bids", + ) + }) + }) }) diff --git a/sdk/packages/sdk/src/types/index.ts b/sdk/packages/sdk/src/types/index.ts index 1d4b4634ae..59aa5e017a 100644 --- a/sdk/packages/sdk/src/types/index.ts +++ b/sdk/packages/sdk/src/types/index.ts @@ -1481,7 +1481,10 @@ export interface BidStorageEntry { * Matches the Rust struct: Bid { filler: AccountId, user_op: Vec } */ export interface FillerBid { - /** The filler's Substrate account ID (SS58 encoded) */ + /** + * The filler's Substrate account ID (SS58 encoded) for a bid read from Hyperbridge. The HyperFX + * orderbook does not know it, so a bid read from there carries the solver's address instead. + */ filler: string /** * Which of the filler's bids on the order this is. A filler offering several prices bids once @@ -1490,8 +1493,14 @@ export interface FillerBid { bid: HexString /** The decoded PackedUserOperation */ userOp: PackedUserOperation - /** The deposit amount stored on-chain (in plancks) */ + /** The deposit amount stored on-chain (in plancks); 0 for a bid read from the orderbook. */ deposit: bigint + /** + * Where the bid was read from. Absent means Hyperbridge: a solver posts to both when it holds + * BRIDGE for the deposit and to the orderbook alone when it does not, and the executor merges the + * two by bid identifier. + */ + venue?: "hyperbridge" | "orderbook" } /** diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-10-21-bids-on-the-orderbook.md b/sdk/packages/simplex/docs/ai/changelog/2026-10-21-bids-on-the-orderbook.md new file mode 100644 index 0000000000..a32f019dab --- /dev/null +++ b/sdk/packages/simplex/docs/ai/changelog/2026-10-21-bids-on-the-orderbook.md @@ -0,0 +1,67 @@ +# 2026-10-21 — Bids go to the orderbook too, and to it alone without BRIDGE + +The HyperFX orderbook now takes bids on swappers' orders (`submitBid(userOp)`) and serves them +under the order's commitment (`bids(commitment, leg, solver)`). Simplex posts every bid there, and +the SDK reads bids from there beside Hyperbridge, so a solver can bid without a BRIDGE balance. + +## Where a bid goes + +`FillerStrategy.executeOrder(order, bids?: BidPlacer)` takes the filler's bid venues in place of +the raw `IntentsCoprocessor`. The strategy still builds and signs the fill UserOp; `BidVenues` +(`src/core/bid-venues.ts`) decides who receives it: + +- **The orderbook, always.** The same SCALE-encoded op Hyperbridge takes. The orderbook accepts it + only from a solver holding an active limit order on the leg's book filling on the order's + destination (`NO_LIMIT_ORDER`), serves it for `serverInfo.bidTtlSecs` (600 s on testnet), and + has no way to withdraw it; the op's own `validUntil` bounds execution as before. +- **Hyperbridge, when the account holds BRIDGE for it.** `BridgeBalanceGate` reads the account's + free balance over the coprocessor's query API and lets a bid through while it covers one bid's + cost: the pallet's storage deposit (the on-chain `StorageDepositFee` when set, else the runtime + constant), the existential deposit, and the extrinsic fee as `paymentInfo` estimates it. The + reading stands for `BRIDGE_BALANCE_TTL_MS` (30 s), is lowered by one bid's cost after each + placement that landed or pooled, and is zeroed when Hyperbridge answers `InsufficientBalance` + or refuses the fee. A read that fails lets the bid through; Hyperbridge's own refusal costs + nothing. Without BRIDGE the bid goes to the orderbook alone, logged once at `warn` until the + balance is back. + +The two venues are posted side by side. `FillResult.success`, `txHash`, `pending` and `error` +remain Hyperbridge's answer; `FillResult.orderbook` is the orderbook's (`SubmitBidResult`: +`accepted`, `unchanged`, `rejected` with a `RejectionCode`, or `failed`, including +`REQUEST_FAILED` for a request that got no answer). A bid is *placed* when Hyperbridge took or +pooled it or the orderbook holds it; that is what `orderExecuted.success` and the activity's +`bid` row now mean. Order intake no longer refuses a solver-selection order for want of a +Hyperbridge connection while an orderbook is configured. + +`IntentFiller.setOrderbook(client)` hands the filler the `OrderbookClient` boot already builds +for `LimitOrderService`. + +## What the bid store records + +`StoredBid.onOrderbook` (and `BidInsert.onOrderbook`, SQLite column `on_orderbook`, added in +place on older databases) says the orderbook holds the bid. It never changes: nothing withdraws +an orderbook bid. `success` and `pending` keep their Hyperbridge meaning, so: + +- The retraction sweep, `retractBidsForLimitOrder` and the fill-time retractions touch only rows + with a Hyperbridge deposit (`success || pending`). An orderbook-only row is left to expire. +- Fill attribution (`recordExecutedFill`) counts an orderbook-only row as a live candidate, inside + the same window a retracted bid gets (`bidValiditySeconds` + discovery pad + permit skew, back + from the fill's block time), since nothing retracts it and an expired one cannot have executed. + +A custom `BidStore` that drops `onOrderbook` loses fill attribution for orderbook-only bids, not +funds. `BidDto.onOrderbook` reaches the dashboard, whose order history shows **Orderbook** for a +bid Hyperbridge did not take but the orderbook holds. + +`OrderbookClient.submitBid(userOp)` is the new document; `REJECTION_CODES` gains `NO_LIMIT_ORDER` +and `TOO_MANY_BIDS`. The schema fixture is refreshed from `polytope-labs/hyperfx-orderbook` main. + +## SDK: bids from both sources + +`HyperFxOrderbook.bids(commitment, { leg?, solver? })` returns `OrderbookBid[]` (solver, +commitment, leg, book, side, chains, tokens, `price`, `amountIn`, `amountOut` at 1e18, +`receivedAt`, `expiresAt`, and the SCALE-encoded `userOp`). `OrderExecutor.fetchBids` reads the +coprocessor (when configured) and the gateway's orderbook together and merges by +`(sender, bidId)`, where the bid id is `keccak256(callData)` as Hyperbridge files it; the +Hyperbridge copy wins a tie since it carries the deposit. One source failing leaves the other's +bids; both failing throws. `FillerBid.venue` is `"orderbook"` for a bid read there (its `filler` +is the solver's address, `deposit` is 0). `IntentGateway.executeOrder` no longer requires a +coprocessor, and `withOrderbook` now also redirects where bids are read from. diff --git a/sdk/packages/simplex/src/core/bid-venues.ts b/sdk/packages/simplex/src/core/bid-venues.ts new file mode 100644 index 0000000000..785d5bc28c --- /dev/null +++ b/sdk/packages/simplex/src/core/bid-venues.ts @@ -0,0 +1,209 @@ +import type { BidSubmissionResult, HexString, IntentsCoprocessor } from "@hyperbridge/sdk" +import type { OrderbookClient } from "@/orderbook/client" +import type { SubmitBidResult } from "@/orderbook/types" +import type { Logger } from "@/services/Logger" + +/** + * Where a bid goes. A strategy builds the signed fill op and hands it here; the venues decide + * who receives it. The filler passes one of these to `FillerStrategy.executeOrder`. + */ +export interface BidPlacer { + /** + * @param commitment - The swap order the bid is on. + * @param userOp - The signed fill UserOp, SCALE-encoded: the same bytes every venue takes. + * @param bid - The identifier Hyperbridge files the bid under, `keccak256` of its calldata. + */ + place(commitment: HexString, userOp: HexString, bid: HexString): Promise +} + +/** What each venue said. A venue the bid was not sent to is absent. */ +export interface BidPlacement { + /** Hyperbridge's answer: the extrinsic landed, is pooled, or failed. */ + hyperbridge?: BidSubmissionResult + /** The HyperFX orderbook's answer. */ + orderbook?: SubmitBidResult +} + +/** + * How long a reading of the filler's BRIDGE balance stands before it is read again. Bids are + * placed in bursts, and each one that reaches Hyperbridge lowers the balance by what it + * reserves and pays, so the reading is also lowered locally after each placement. + */ +export const BRIDGE_BALANCE_TTL_MS = 30_000 + +/** + * Whether the filler holds the BRIDGE a bid on Hyperbridge costs. + * + * `place_bid` reserves `StorageDepositFee` and the extrinsic pays its fee out of the free + * balance, which must also stay above the existential deposit. A filler without that much + * cannot bid on Hyperbridge at all: the extrinsic fails with `InsufficientBalance` or is + * refused at the pool for its fee, and either way pays nothing and places nothing. So the + * balance is read before each bid and, when it falls short, the bid goes to the orderbook alone. + * + * The reading is cached for {@link BRIDGE_BALANCE_TTL_MS} and lowered by one bid's cost after + * each placement that reached Hyperbridge, so a burst of bids does not overrun a balance the + * next read has not caught up with. A placement Hyperbridge refuses for want of balance + * zeroes the reading until the next read. The read is over the coprocessor's query API; a read + * that fails leaves the last reading in place, or, with none, lets the bid through to + * Hyperbridge, whose own refusal costs nothing. + */ +export class BridgeBalanceGate { + private free?: bigint + private readAt = 0 + private cost?: bigint + private read?: Promise + + constructor( + private readonly hyperbridge: IntentsCoprocessor, + private readonly logger: Logger, + private readonly now: () => number = Date.now, + ) {} + + /** Whether the account can afford one more bid on Hyperbridge. */ + async canPlaceBid(): Promise { + if (this.free === undefined || this.now() - this.readAt > BRIDGE_BALANCE_TTL_MS) { + this.read ??= this.refresh().finally(() => { + this.read = undefined + }) + await this.read + } + if (this.free === undefined || this.cost === undefined) return true + return this.free >= this.cost + } + + /** + * Lowers the reading after a bid reached Hyperbridge (landed or pooled): its deposit is + * reserved and its fee paid, before the next read sees either. + */ + spent(): void { + if (this.free !== undefined && this.cost !== undefined) { + this.free = this.free > this.cost ? this.free - this.cost : 0n + } + } + + /** Hyperbridge refused a bid for want of balance: nothing more goes there until the next read. */ + exhausted(): void { + this.free = 0n + this.readAt = this.now() + } + + /** The free balance in BRIDGE's smallest unit, as last read; undefined before the first read. */ + get freeBalance(): bigint | undefined { + return this.free + } + + private async refresh(): Promise { + try { + const api = await this.hyperbridge.queryApi() + const address = this.hyperbridge.getKeyPair().address + // biome-ignore lint/suspicious/noExplicitAny: polkadot-js codec + const account = (await api.query.system.account(address)) as any + this.free = BigInt(account.data.free.toString()) + this.readAt = this.now() + if (this.cost === undefined) this.cost = await this.bidCost(api) + } catch (err) { + this.logger.warn({ err }, "Could not read the BRIDGE balance; bids go to Hyperbridge as usual") + } + } + + /** + * What one bid costs in free balance: the storage deposit the pallet reserves (the runtime's + * current value, or its constant when none has been set), the existential deposit the free + * balance must keep, and the extrinsic's fee as estimated for a representative `place_bid`. + */ + // biome-ignore lint/suspicious/noExplicitAny: polkadot-js api + private async bidCost(api: any): Promise { + const stored = BigInt((await api.query.intentsCoprocessor.storageDepositFee()).toString()) + const deposit = stored > 0n ? stored : BigInt(api.consts.intentsCoprocessor.storageDepositFee.toString()) + const existential = BigInt(api.consts.balances.existentialDeposit.toString()) + let fee = 0n + try { + const zero = `0x${"00".repeat(32)}` + const info = await api.tx.intentsCoprocessor + .placeBid(zero, zero, `0x${"00".repeat(512)}`) + .paymentInfo(this.hyperbridge.getKeyPair().address) + fee = BigInt(info.partialFee.toString()) + } catch (err) { + this.logger.debug({ err }, "Could not estimate the bid fee; counting the deposit alone") + } + return deposit + existential + fee + } +} + +/** Hyperbridge's answer when the account cannot pay for the bid, as the SDK renders it. */ +function wantsBalance(result: BidSubmissionResult): boolean { + return ( + !result.success && + !result.pending && + /InsufficientBalance|Inability to pay some fees|FundsUnavailable|1010/.test(result.error ?? "") + ) +} + +/** + * The venues a bid goes to: Hyperbridge when the filler holds BRIDGE for the deposit, and the + * HyperFX orderbook always. The two are posted side by side, since Hyperbridge waits for its + * extrinsic to land and the orderbook answers at once. One venue's failure is not the other's: + * each answer is returned on its own, and the caller decides what the bid is worth from them. + */ +export class BidVenues implements BidPlacer { + private hadBalance?: boolean + + constructor( + private readonly venues: { + hyperbridge?: IntentsCoprocessor + orderbook?: OrderbookClient + /** Absent when there is no Hyperbridge connection to read, so no gate to pass. */ + bridgeBalance?: BridgeBalanceGate + }, + private readonly logger: Logger, + ) {} + + async place(commitment: HexString, userOp: HexString, bid: HexString): Promise { + const { hyperbridge, orderbook, bridgeBalance } = this.venues + const toHyperbridge = hyperbridge !== undefined && (bridgeBalance === undefined || (await bridgeBalance.canPlaceBid())) + if (hyperbridge && !toHyperbridge && this.hadBalance !== false) { + this.logger.warn( + { free: bridgeBalance?.freeBalance?.toString() }, + "No BRIDGE for a bid deposit; bids go to the orderbook alone until the balance is topped up", + ) + } else if (toHyperbridge && this.hadBalance === false) { + this.logger.info("BRIDGE balance restored; bids go to Hyperbridge and the orderbook again") + } + if (hyperbridge) this.hadBalance = toHyperbridge + + const [onHyperbridge, onOrderbook] = await Promise.all([ + toHyperbridge ? hyperbridge.submitBid(commitment, userOp, bid) : undefined, + orderbook ? this.postToOrderbook(commitment, userOp) : undefined, + ]) + if (onHyperbridge && bridgeBalance) { + if (onHyperbridge.success || onHyperbridge.pending) bridgeBalance.spent() + else if (wantsBalance(onHyperbridge)) bridgeBalance.exhausted() + } + return { + ...(onHyperbridge ? { hyperbridge: onHyperbridge } : {}), + ...(onOrderbook ? { orderbook: onOrderbook } : {}), + } + } + + /** The orderbook's answer, with a request that never got one rendered as a failed submission. */ + private async postToOrderbook(commitment: HexString, userOp: HexString): Promise { + try { + const result = await this.venues.orderbook!.submitBid(userOp) + if (result.kind === "rejected") { + this.logger.info({ commitment, code: result.code, message: result.message }, "The orderbook refused the bid") + } else if (result.kind === "failed") { + this.logger.warn({ commitment, code: result.code, message: result.message }, "The orderbook could not take the bid") + } + return result + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + this.logger.warn({ commitment, err }, "Could not post the bid to the orderbook") + return { kind: "failed", code: "REQUEST_FAILED", message, retryable: true } + } + } +} + +/** Whether the orderbook holds the bid after this answer: it took it now, or already had it. */ +export function orderbookHoldsBid(result: SubmitBidResult | undefined): boolean { + return result?.kind === "accepted" || result?.kind === "unchanged" +} diff --git a/sdk/packages/simplex/src/core/boot.ts b/sdk/packages/simplex/src/core/boot.ts index 201079927f..7c965952a3 100644 --- a/sdk/packages/simplex/src/core/boot.ts +++ b/sdk/packages/simplex/src/core/boot.ts @@ -413,13 +413,17 @@ export async function bootFiller(config: FillerTomlConfig, options: BootOptions) // that reached here has one. Checked rather than asserted: boot is also entered // from the setup API, and a filler with no orderbook has nothing to price from. if (!config.orderbook) throw new Error("an [orderbook] section is required") + const orderbookClient = new OrderbookClient( + config.orderbook.url, + config.orderbook.requestTimeoutMs ?? DEFAULT_ORDERBOOK_TIMEOUT_MS, + options.loggers, + ) + // Every bid is posted to the orderbook too, and to it alone when the account holds no + // BRIDGE for the Hyperbridge deposit. + intentFiller.setOrderbook(orderbookClient) const limitOrderService = new LimitOrderService( options.data.limitOrders, - new OrderbookClient( - config.orderbook.url, - config.orderbook.requestTimeoutMs ?? DEFAULT_ORDERBOOK_TIMEOUT_MS, - options.loggers, - ), + orderbookClient, contractService, configService, assetRegistry, diff --git a/sdk/packages/simplex/src/core/filler.ts b/sdk/packages/simplex/src/core/filler.ts index 6d2f2c8ca9..3842e75d6b 100644 --- a/sdk/packages/simplex/src/core/filler.ts +++ b/sdk/packages/simplex/src/core/filler.ts @@ -27,7 +27,9 @@ import { sqliteDatetime } from "@/data/memory" import type { BidPricing, BidStore, LimitOrderStore, StoredBid } from "@/data/types" import type { AssetRegistry } from "@/config/asset-registry" import type { LimitOrderService } from "@/orderbook/limit-orders" +import type { OrderbookClient } from "@/orderbook/client" import { toRaw, toScaled, toScaledUp } from "@/orderbook/amounts" +import { BidVenues, BridgeBalanceGate, orderbookHoldsBid } from "./bid-venues" import type { OrderScanner } from "@/scanner/types" import type { FillerConfigService } from "@/services/FillerConfigService" import type { QuorumPublicClient, TransactionInclusion } from "@/services/QuorumPublicClient" @@ -76,6 +78,14 @@ function isLiveBid(row: StoredBid): boolean { return !row.retracted && (row.success || row.pending) } +/** + * A bid the orderbook holds that Hyperbridge never did. Nothing retracts it: it stands until + * its op's `validUntil`, so for attribution it is bounded by the same window a retracted bid is. + */ +function isOrderbookOnly(row: StoredBid): boolean { + return row.onOrderbook && !row.success && !row.pending +} + /** Smallest payout first, for picking the tightest one that covers a delivery. */ function byPayout(a: BidPricing, b: BidPricing): number { return BigInt(a.payout) < BigInt(b.payout) ? -1 : BigInt(a.payout) > BigInt(b.payout) ? 1 : 0 @@ -174,6 +184,16 @@ export class IntentFiller { * built from this same store, so it cannot exist before the filler does. */ private limitOrderService?: LimitOrderService + /** + * Set after construction, like the service: the orderbook every bid is posted to, beside + * Hyperbridge. Absent only in tests that bid on Hyperbridge alone. + */ + private orderbookClient?: OrderbookClient + /** + * Whether the Hyperbridge account can pay for a bid, read once the connection is up. A bid + * the account cannot pay for goes to the orderbook alone. + */ + private bridgeBalance?: BridgeBalanceGate private retractionQueue: pQueue /** * Fills are settled one at a time, in the order they were scanned. Several fills of @@ -343,6 +363,14 @@ export class IntentFiller { this.limitOrderService = service } + /** + * Hands the filler the orderbook its bids are posted to. Every bid goes there; it goes to + * Hyperbridge as well when the account holds BRIDGE for the deposit. + */ + public setOrderbook(client: OrderbookClient): void { + this.orderbookClient = client + } + /** * Brings the limit orders' `remaining` in line with their on-chain tallies. * @@ -400,6 +428,9 @@ export class IntentFiller { public async initialize(): Promise { await this.connectHyperbridge() + if (this.hyperbridge) { + this.bridgeBalance = new BridgeBalanceGate(await this.hyperbridge, this.logger) + } const chains = await this.solverSelectionChains(this.configService.getConfiguredChainIds()) // Boot tolerates partial failure: as long as one chain delegated, the // filler is useful. Only a total failure is fatal. @@ -1056,10 +1087,10 @@ export class IntentFiller { }) return } - if (solverSelectionActive && !this.hyperbridge) { + if (solverSelectionActive && !this.hyperbridge && !this.orderbookClient) { this.logger.error( { orderId: order.id }, - "Solver selection is active but Hyperbridge is not configured. Skipping order.", + "Solver selection is active but neither Hyperbridge nor an orderbook is configured. Skipping order.", ) return } @@ -1542,9 +1573,11 @@ export class IntentFiller { try { const execStartMs = Date.now() - const hyperbridgeService = solverSelectionActive ? await this.hyperbridge : undefined - const result = await bestStrategy.executeOrder(order, hyperbridgeService) + const venues = solverSelectionActive ? await this.bidVenues() : undefined + const result = await bestStrategy.executeOrder(order, venues) const execDurationSec = (Date.now() - execStartMs) / 1000 + // Live somewhere: Hyperbridge took it or has it pooled, or the orderbook holds it. + const placed = result.success || result.pending === true || orderbookHoldsBid(result.orderbook) this.monitor.emit("orderTiming", { orderId: order.id, phase: "execution", @@ -1567,6 +1600,7 @@ export class IntentFiller { success: result.success, pending: result.pending === true, error: result.error, + onOrderbook: orderbookHoldsBid(result.orderbook), pricedBy, }) @@ -1608,7 +1642,7 @@ export class IntentFiller { } } - if (result.success) { + if (result.success || (placed && !result.pending)) { this.monitor.emit("orderFilled", { orderId: order.id, hash: result.txHash, @@ -1616,17 +1650,20 @@ export class IntentFiller { profitUsd, chainId: getChainId(order.source), // Under solver selection "success" means the bid was accepted by - // Hyperbridge, not that the order is filled; the commitment says which. + // Hyperbridge or the orderbook, not that the order is filled; the + // commitment says which. commitment: result.commitment, }) } this.monitor.emit("orderExecuted", { orderId: order.id, - success: result.success, + // An accepted bid, on either venue. A bid Hyperbridge refused that the + // orderbook holds is a bid a swapper can execute, not a failure. + success: placed, txHash: result.txHash, strategy: bestStrategy.name, commitment: result.commitment, - error: result.error, + error: placed ? undefined : result.error, }) lastResult = result @@ -1648,6 +1685,21 @@ export class IntentFiller { }).catch((err) => this.logger.error({ orderId: order.id, err }, "Order execution task failed")) } + /** + * The venues a bid goes to. The orderbook always; Hyperbridge when there is a connection and + * the account holds BRIDGE for the deposit, which the gate decides per bid. + */ + private async bidVenues(): Promise { + return new BidVenues( + { + hyperbridge: this.hyperbridge ? await this.hyperbridge : undefined, + orderbook: this.orderbookClient, + bridgeBalance: this.bridgeBalance, + }, + this.logger, + ) + } + private async handleOrderFilledOnChain( commitment: HexString, filler: string, @@ -1850,16 +1902,18 @@ export class IntentFiller { } const filledBids = new Set(recorded.map((fill) => fill.bid).filter((bid) => bid !== null)) - const unfilled = rows.filter((row) => (row.success || row.pending) && !(row.bid && filledBids.has(row.bid))) + const unfilled = rows.filter( + (row) => (row.success || row.pending || row.onOrderbook) && !(row.bid && filledBids.has(row.bid)), + ) // Counted back from when the fill happened. Settlement waits behind the queue, so // counting from now would stretch the window by however long it waited and // attribute the fill to a bid that could no longer have executed. The time only - // matters to a retracted bid, which is rare, so the block is not read otherwise. - const filledAtMs = unfilled.some((row) => row.retracted) - ? await this.fillTimeMs(chainId, blockNumber, seenAtMs) - : seenAtMs + // matters to a retracted bid or one the orderbook alone held, which nothing + // retracts, so the block is not read otherwise. + const windowed = (row: StoredBid) => row.retracted || isOrderbookOnly(row) + const filledAtMs = unfilled.some(windowed) ? await this.fillTimeMs(chainId, blockNumber, seenAtMs) : seenAtMs const executableSince = sqliteDatetime(new Date(filledAtMs - this.retractedBidWindowMs())) - const candidates = unfilled.filter((row) => !row.retracted || row.createdAt >= executableSince) + const candidates = unfilled.filter((row) => !windowed(row) || row.createdAt >= executableSince) if (candidates.length === 0) return const released = inputs.reduce((sum, input) => sum + input.amount, 0n) diff --git a/sdk/packages/simplex/src/data/memory.ts b/sdk/packages/simplex/src/data/memory.ts index ada576fbbe..b592513135 100644 --- a/sdk/packages/simplex/src/data/memory.ts +++ b/sdk/packages/simplex/src/data/memory.ts @@ -74,6 +74,7 @@ class MemoryBidStore implements BidStore { retractedAt: null, retractExtrinsicHash: null, dead: false, + onOrderbook: bid.onOrderbook === true, pricedBy: bid.pricedBy ?? null, }) if (this.rows.length > MAX_ROWS) { diff --git a/sdk/packages/simplex/src/data/sqlite/bids.ts b/sdk/packages/simplex/src/data/sqlite/bids.ts index ab102d05e4..c9dda5c83d 100644 --- a/sdk/packages/simplex/src/data/sqlite/bids.ts +++ b/sdk/packages/simplex/src/data/sqlite/bids.ts @@ -19,6 +19,7 @@ const BID_COLUMNS = ` retracted_at as retractedAt, retract_extrinsic_hash as retractExtrinsicHash, dead, + on_orderbook as onOrderbook, priced_by as pricedBy, reservations ` @@ -88,6 +89,7 @@ export class SqliteBidStore implements BidStore { retracted_at TEXT, retract_extrinsic_hash TEXT, dead INTEGER NOT NULL DEFAULT 0, + on_orderbook INTEGER NOT NULL DEFAULT 0, -- Only read, as the provenance of a bid written before priced_by. Kept, and -- never written, so a rollback to a build that still reads it finds the column. reservations TEXT, @@ -102,7 +104,7 @@ export class SqliteBidStore implements BidStore { // Databases created before a column existed need it added in place. const columns = columnNames(this.db, "bids") - for (const column of ["dead", "pending"] as const) { + for (const column of ["dead", "pending", "on_orderbook"] as const) { if (columns.has(column)) continue this.db.exec(`ALTER TABLE bids ADD COLUMN ${column} INTEGER NOT NULL DEFAULT 0`) this.logger.info({ column }, "Migrated bid storage schema") @@ -130,6 +132,7 @@ export class SqliteBidStore implements BidStore { pending: Boolean(row.pending), retracted: Boolean(row.retracted), dead: Boolean(row.dead), + onOrderbook: Boolean(row.onOrderbook), pricedBy: pricedBy(row), } } @@ -137,8 +140,8 @@ export class SqliteBidStore implements BidStore { async store(bid: BidInsert): Promise { const result = this.db .prepare(` - INSERT INTO bids (commitment, bid, extrinsic_hash, block_hash, success, pending, error, priced_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO bids (commitment, bid, extrinsic_hash, block_hash, success, pending, error, on_orderbook, priced_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `) .run( bid.commitment, @@ -148,6 +151,7 @@ export class SqliteBidStore implements BidStore { bid.success ? 1 : 0, bid.pending ? 1 : 0, bid.error || null, + bid.onOrderbook ? 1 : 0, bid.pricedBy ? JSON.stringify(bid.pricedBy) : null, ) diff --git a/sdk/packages/simplex/src/data/types.ts b/sdk/packages/simplex/src/data/types.ts index 26d35bd7c0..197300a241 100644 --- a/sdk/packages/simplex/src/data/types.ts +++ b/sdk/packages/simplex/src/data/types.ts @@ -55,6 +55,13 @@ export interface StoredBid { retractExtrinsicHash: string | null /** The order was seen filled on-chain, so this bid can never win — reclaim its deposit now. */ dead: boolean + /** + * The HyperFX orderbook took this bid, so a swapper can execute it whatever Hyperbridge said. + * The orderbook serves a bid for its TTL and nothing withdraws it, so this never changes; the + * bid is live there until its op's `validUntil`. False for a bid the orderbook refused or was + * not offered, and for rows from before bids went to the orderbook. + */ + onOrderbook: boolean /** * The limit order that priced this bid and the terms it signed, written when the bid is stored * and never changed. Null for a bid no limit order priced. @@ -103,6 +110,8 @@ export interface BidInsert { /** Submission is still in the pool; see {@link StoredBid.pending}. */ pending?: boolean error?: string + /** The orderbook holds this bid; see {@link StoredBid.onOrderbook}. */ + onOrderbook?: boolean } export interface BidStats { diff --git a/sdk/packages/simplex/src/orderbook/client.ts b/sdk/packages/simplex/src/orderbook/client.ts index 253bfccc82..8692d58eab 100644 --- a/sdk/packages/simplex/src/orderbook/client.ts +++ b/sdk/packages/simplex/src/orderbook/client.ts @@ -10,10 +10,12 @@ import type { OrderbookFilters, OrderbookLevelOrder, OrderbookLevelQuery, + PostedBid, PostedOrder, PostedOrderPage, RejectionCode, SolverStatus, + SubmitBidResult, SubmitOrderResult, } from "./types" @@ -82,6 +84,20 @@ const SUBMIT_ORDER_MUTATION = ` } ` +const POSTED_BID_FIELDS = "commitment leg side fillChain sourceChain price amountIn amountOut expiresAt" + +const SUBMIT_BID_MUTATION = ` + mutation SubmitBid($userOp: Bytes!) { + submitBid(userOp: $userOp) { + __typename + ... on BidAccepted { bid { ${POSTED_BID_FIELDS} } } + ... on BidUnchanged { bid { ${POSTED_BID_FIELDS} } } + ... on BidRejected { code message } + ... on BidSubmissionFailed { failureCode: code message retryable } + } + } +` + const ORDER_QUERY = ` query OrderAt($solver: Address!, $commitment: Bytes!) { order(solver: $solver, commitment: $commitment) { ${POSTED_ORDER_FIELDS} } @@ -132,6 +148,7 @@ export const ORDERBOOK_DOCUMENTS = { snapshot: SNAPSHOT_QUERY, levelOrders: LEVEL_ORDERS_QUERY, submitOrder: SUBMIT_ORDER_MUTATION, + submitBid: SUBMIT_BID_MUTATION, heartbeat: HEARTBEAT_MUTATION, myOrders: MY_ORDERS_QUERY, cancelOrder: CANCEL_ORDER_MUTATION, @@ -255,6 +272,35 @@ export class OrderbookClient { } } + /** + * Posts a bid on a swapper's order: the signed fill UserOp, SCALE-encoded, exactly as it goes + * to Hyperbridge. The orderbook verifies it as it does a limit order, except that it need not + * be phantom and carries no TTL, and accepts it only from a solver holding an active limit + * order on the leg's book filling on the order's destination (`NO_LIMIT_ORDER`). It serves + * the bid under the order's commitment for `serverInfo.bidTtlSecs`; there is no withdrawing + * it before then. + */ + async submitBid(userOp: HexString): Promise { + const { submitBid } = await this.request<{ submitBid: RawSubmitBid }>(SUBMIT_BID_MUTATION, { userOp }) + switch (submitBid.__typename) { + case "BidAccepted": + return { kind: "accepted", bid: submitBid.bid! } + case "BidUnchanged": + return { kind: "unchanged", bid: submitBid.bid! } + case "BidRejected": + return { kind: "rejected", code: submitBid.code as RejectionCode, message: submitBid.message! } + case "BidSubmissionFailed": + return { + kind: "failed", + code: submitBid.failureCode!, + message: submitBid.message!, + retryable: submitBid.retryable ?? false, + } + default: + throw new OrderbookRequestError(`Unknown submitBid result ${submitBid.__typename}`) + } + } + /** * The entry this solver holds at `commitment`, or null when it holds none. * @@ -374,6 +420,16 @@ interface RawSubmitOrder { retryable?: boolean } +interface RawSubmitBid { + __typename: string + bid?: PostedBid + code?: string + /** `BidSubmissionFailed.code` under an alias, for the same reason as {@link RawSubmitOrder.failureCode}. */ + failureCode?: FailureCode + message?: string + retryable?: boolean +} + interface RawHeartbeat { __typename: string reactivatedOrders?: number diff --git a/sdk/packages/simplex/src/orderbook/types.ts b/sdk/packages/simplex/src/orderbook/types.ts index 3175179c5b..562f495927 100644 --- a/sdk/packages/simplex/src/orderbook/types.ts +++ b/sdk/packages/simplex/src/orderbook/types.ts @@ -183,6 +183,9 @@ export const REJECTION_CODES = [ "MISSING_DECLARATION", "EMPTY_DECLARATION", "UNSUPPORTED_SOURCE_CHAIN", + // A bid's alone: it is held to the solver's standing on the book, not to a TTL or a declaration. + "NO_LIMIT_ORDER", + "TOO_MANY_BIDS", ] as const export type RejectionCode = (typeof REJECTION_CODES)[number] @@ -202,6 +205,35 @@ export type SubmitOrderResult = | { kind: "rejected"; code: RejectionCode; message: string } | { kind: "failed"; code: FailureCode | "REQUEST_FAILED"; message: string; retryable: boolean } +/** + * A bid on a swapper's order as the orderbook holds it, under the order's commitment. Amounts + * and the price are decimal strings at 1e18. + */ +export interface PostedBid { + commitment: HexString + /** Which of the order's legs the bid quotes, from 0. */ + leg: number + side: "BID" | "ASK" + fillChain: string + sourceChain: string + /** The rate the bid signed, quote per 1 base, before the protocol fee. */ + price: string + amountIn: string + amountOut: string + /** ISO-8601: when the orderbook stops serving the bid (`receivedAt` plus `serverInfo.bidTtlSecs`). */ + expiresAt: string +} + +/** + * The orderbook's answer to a bid. `unchanged` is the same op posted again while its bid is + * still live, which changed nothing; a bid is served until its TTL and cannot be withdrawn. + */ +export type SubmitBidResult = + | { kind: "accepted"; bid: PostedBid } + | { kind: "unchanged"; bid: PostedBid } + | { kind: "rejected"; code: RejectionCode; message: string } + | { kind: "failed"; code: FailureCode | "REQUEST_FAILED"; message: string; retryable: boolean } + /** Why a signed message was refused, shared by `cancelOrder` and `heartbeat`. */ export const MESSAGE_REJECTION_CODES = [ "BAD_SIGNATURE", diff --git a/sdk/packages/simplex/src/services/server/dto.ts b/sdk/packages/simplex/src/services/server/dto.ts index a50ee8d61b..8acf6624a8 100644 --- a/sdk/packages/simplex/src/services/server/dto.ts +++ b/sdk/packages/simplex/src/services/server/dto.ts @@ -219,8 +219,11 @@ export interface BidDto { id: number commitment: string extrinsicHash: string | null + /** Hyperbridge took the bid. */ success: boolean error: string | null + /** The HyperFX orderbook took the bid, which a swapper can execute whether or not Hyperbridge did. */ + onOrderbook: boolean /** SQLite-style "YYYY-MM-DD HH:MM:SS" in UTC. */ createdAt: string retracted: boolean diff --git a/sdk/packages/simplex/src/strategies/base.ts b/sdk/packages/simplex/src/strategies/base.ts index 12427cd220..fdbab28a93 100644 --- a/sdk/packages/simplex/src/strategies/base.ts +++ b/sdk/packages/simplex/src/strategies/base.ts @@ -1,13 +1,20 @@ -import type { Order, ExecutionResult, HexString, IntentsCoprocessor } from "@hyperbridge/sdk" +import type { Order, ExecutionResult, HexString } from "@hyperbridge/sdk" +import type { Decimal } from "decimal.js" +import type { BidPlacer } from "@/core/bid-venues" +import type { SubmitBidResult } from "@/orderbook/types" /** - * An execution outcome that can also report a still-pooled bid. + * An execution outcome that can also report a still-pooled bid, and what the orderbook said. + * + * `success`, `txHash`, `pending` and `error` are Hyperbridge's answer. `submitBid` can time out + * with the extrinsic still in Hyperbridge's pool. That is neither success nor failure: the + * extrinsic may yet land and reserve a deposit, so the bid record has to be reclaimable even + * though `success` is false. Widened here rather than on the SDK's `ExecutionResult` so simplex + * does not need an unreleased SDK. * - * `submitBid` can time out with the extrinsic still in Hyperbridge's pool. That - * is neither success nor failure: the extrinsic may yet land and reserve a - * deposit, so the bid record has to be reclaimable even though `success` is - * false. Widened here rather than on the SDK's `ExecutionResult` so simplex does - * not need an unreleased SDK. + * A bid goes to the HyperFX orderbook as well, and to it alone when the filler holds no BRIDGE + * for the Hyperbridge deposit; `orderbook` is that venue's answer. A bid the orderbook holds is + * live whatever Hyperbridge said. */ export interface FillResult extends ExecutionResult { pending?: boolean @@ -16,8 +23,9 @@ export interface FillResult extends ExecutionResult { * `keccak256` of its calldata. It tells the bids on one order apart, and retracting the bid names it. */ bid?: HexString + /** The orderbook's answer; absent when the bid was not offered to it. */ + orderbook?: SubmitBidResult } -import type { Decimal } from "decimal.js" export interface FillerStrategy { name: string @@ -26,7 +34,12 @@ export interface FillerStrategy { calculateProfitability(order: Order): Promise - executeOrder(order: Order, hyperbridge?: IntentsCoprocessor): Promise + /** + * Places the strategy's bid on `order` through `bids`, the venues the filler bids on + * (Hyperbridge and the HyperFX orderbook). Without `bids` the destination selects no + * solver, so there is nothing to bid on. + */ + executeOrder(order: Order, bids?: BidPlacer): Promise /** * Optional hook for strategies to provide a USD value for the full input basket. diff --git a/sdk/packages/simplex/src/strategies/fx.ts b/sdk/packages/simplex/src/strategies/fx.ts index b304877751..61dc09690c 100644 --- a/sdk/packages/simplex/src/strategies/fx.ts +++ b/sdk/packages/simplex/src/strategies/fx.ts @@ -1,12 +1,11 @@ import type { FillResult, FillerStrategy } from "@/strategies/base" +import { type BidPlacer, orderbookHoldsBid } from "@/core/bid-venues" import { type Order, - type ExecutionResult, type HexString, bytes32ToBytes20, type ERC7821Call, type TokenInfo, - type IntentsCoprocessor, ADDRESS_ZERO, } from "@hyperbridge/sdk" import type { ChainClientManager, ContractInteractionService } from "@/services" @@ -883,25 +882,25 @@ export class FXFiller implements FillerStrategy { } /** - * Executes an order by submitting a bid via the IntentsCoprocessor. + * Executes an order by placing a bid through the filler's venues. * * Assumes that `calculateProfitability` has already been called for the * given order so that filler outputs are cached in `contractService`. * This method only orchestrates the bid construction and submission; the * actual token movements are handled on-chain by the IntentGateway. */ - async executeOrder(order: Order, intentsCoprocessor?: IntentsCoprocessor): Promise { + async executeOrder(order: Order, bids?: BidPlacer): Promise { const startTime = Date.now() try { - if (!intentsCoprocessor) { + if (!bids) { return { success: false, - error: "FXFiller requires the UserOp/Hyperbridge path (intentsCoprocessor must be provided)", + error: "FXFiller requires the UserOp path (the filler's bid venues must be provided)", } } - return await this.submitBid(order, startTime, intentsCoprocessor) + return await this.submitBid(order, startTime, bids) } catch (error) { this.logger.error({ err: error }, "Error executing FX swap order") return { @@ -916,17 +915,14 @@ export class FXFiller implements FillerStrategy { // ========================================================================= /** - * Prepares and submits a bid UserOp to Hyperbridge for the given order. + * Prepares the bid UserOp for the given order and places it through the filler's venues. * * Uses the filler outputs previously cached by `calculateProfitability`. * Approval bundling and UserOp construction are handled by - * `ContractInteractionService.prepareBidUserOp`. + * `ContractInteractionService.prepareBidUserOp`. The result's `success`, `txHash`, + * `pending` and `error` are Hyperbridge's answer; `orderbook` is the orderbook's. */ - private async submitBid( - order: Order, - startTime: number, - intentsCoprocessor: IntentsCoprocessor, - ): Promise { + private async submitBid(order: Order, startTime: number, bids: BidPlacer): Promise { const entryPointAddress = this.configService.getEntryPointAddress(order.destination) if (!entryPointAddress) { return { @@ -944,31 +940,47 @@ export class FXFiller implements FillerStrategy { solverAccountAddress, ) - const bidResult = await intentsCoprocessor.submitBid(commitment, userOp, bid) + const placed = await bids.place(commitment, userOp, bid) + const onHyperbridge = placed.hyperbridge + const onOrderbook = placed.orderbook - const endTime = Date.now() - if (bidResult.success) { - this.logger.info({ commitment }, "Bid submitted successfully") + const processingTimeMs = Date.now() - startTime + const venue = onOrderbook ? { orderbook: onOrderbook } : {} + if (onHyperbridge?.success) { + this.logger.info({ commitment, orderbook: onOrderbook?.kind }, "Bid submitted successfully") return { success: true, - txHash: bidResult.extrinsicHash, + txHash: onHyperbridge.extrinsicHash, strategyUsed: this.name, - processingTimeMs: endTime - startTime, + processingTimeMs, commitment, bid, + ...venue, } } - this.logger.error({ commitment, error: bidResult.error, pending: bidResult.pending }, "Bid submission failed") + if (onHyperbridge) { + this.logger.error( + { commitment, error: onHyperbridge.error, pending: onHyperbridge.pending, orderbook: onOrderbook?.kind }, + "Bid submission failed", + ) + } else if (orderbookHoldsBid(onOrderbook)) { + this.logger.info({ commitment }, "Bid posted to the orderbook") + } else { + this.logger.error({ commitment, orderbook: onOrderbook }, "Bid reached no venue") + } // `pending` and the hash ride along: a pooled extrinsic that later lands // reserves a deposit, so the sweep must be able to find and trace it. return { success: false, - pending: bidResult.pending === true, - txHash: bidResult.extrinsicHash, - error: bidResult.error, + pending: onHyperbridge?.pending === true, + txHash: onHyperbridge?.extrinsicHash, + error: onHyperbridge?.error, + strategyUsed: this.name, + processingTimeMs, commitment, bid, + ...venue, } } diff --git a/sdk/packages/simplex/src/tests/core/bid-venues.test.ts b/sdk/packages/simplex/src/tests/core/bid-venues.test.ts new file mode 100644 index 0000000000..2b6cf623a9 --- /dev/null +++ b/sdk/packages/simplex/src/tests/core/bid-venues.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it, vi } from "vitest" +import type { BidSubmissionResult, HexString } from "@hyperbridge/sdk" +import { BRIDGE_BALANCE_TTL_MS, BidVenues, BridgeBalanceGate } from "@/core/bid-venues" +import type { SubmitBidResult } from "@/orderbook/types" + +/** + * Where a bid goes. Every bid is posted to the HyperFX orderbook; it is posted to Hyperbridge as + * well when the account holds BRIDGE for the deposit, and to the orderbook alone when it does not. + */ + +const COMMITMENT = `0x${"c0".repeat(32)}` as HexString +const USER_OP = "0x0102" as HexString +const BID = `0x${"b1".repeat(32)}` as HexString + +const ACCEPTED: SubmitBidResult = { + kind: "accepted", + bid: { + commitment: COMMITMENT, + leg: 0, + side: "BID", + fillChain: "EVM-97", + sourceChain: "EVM-97", + price: "1500", + amountIn: "1", + amountOut: "1500", + expiresAt: "2026-10-21T00:10:00Z", + }, +} +const LANDED: BidSubmissionResult = { success: true, extrinsicHash: "0xext" as HexString, blockHash: "0xblk" as HexString } + +const logger = { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() } + +function venuesWith(options: { + hyperbridge?: BidSubmissionResult + orderbook?: SubmitBidResult | Error + canPlaceBid?: boolean +}) { + const submitBid = vi.fn(async () => options.hyperbridge ?? LANDED) + const postBid = vi.fn(async () => { + if (options.orderbook instanceof Error) throw options.orderbook + return options.orderbook ?? ACCEPTED + }) + const gate = { + canPlaceBid: vi.fn(async () => options.canPlaceBid ?? true), + spent: vi.fn(), + exhausted: vi.fn(), + freeBalance: 0n, + } + const venues = new BidVenues( + { + hyperbridge: "hyperbridge" in options && options.hyperbridge === undefined ? undefined : ({ submitBid } as never), + orderbook: { submitBid: postBid } as never, + bridgeBalance: gate as never, + }, + logger as never, + ) + return { venues, submitBid, postBid, gate } +} + +describe("BidVenues", () => { + it("posts to Hyperbridge and the orderbook together when the account holds BRIDGE", async () => { + const { venues, submitBid, postBid, gate } = venuesWith({}) + + const placed = await venues.place(COMMITMENT, USER_OP, BID) + + expect(submitBid).toHaveBeenCalledWith(COMMITMENT, USER_OP, BID) + // The same bytes Hyperbridge takes: the signed op, SCALE-encoded. + expect(postBid).toHaveBeenCalledWith(USER_OP) + expect(placed).toEqual({ hyperbridge: LANDED, orderbook: ACCEPTED }) + expect(gate.spent).toHaveBeenCalledOnce() + }) + + it("posts to the orderbook alone when the account holds no BRIDGE, and says so once", async () => { + const { venues, submitBid, postBid, gate } = venuesWith({ canPlaceBid: false }) + + expect(await venues.place(COMMITMENT, USER_OP, BID)).toEqual({ orderbook: ACCEPTED }) + expect(await venues.place(COMMITMENT, USER_OP, BID)).toEqual({ orderbook: ACCEPTED }) + + expect(submitBid).not.toHaveBeenCalled() + expect(postBid).toHaveBeenCalledTimes(2) + expect(gate.spent).not.toHaveBeenCalled() + expect(logger.warn).toHaveBeenCalledTimes(1) + expect(logger.warn.mock.calls[0][1]).toMatch(/orderbook alone/) + }) + + it("zeroes the balance reading when Hyperbridge refuses the bid for want of BRIDGE", async () => { + const refused: BidSubmissionResult = { success: false, error: "Dispatch error: intentsCoprocessor::InsufficientBalance" } + const { venues, gate } = venuesWith({ hyperbridge: refused }) + + const placed = await venues.place(COMMITMENT, USER_OP, BID) + + expect(placed).toEqual({ hyperbridge: refused, orderbook: ACCEPTED }) + expect(gate.exhausted).toHaveBeenCalledOnce() + expect(gate.spent).not.toHaveBeenCalled() + }) + + it("counts a pooled bid as spent, and any other failure as nothing spent", async () => { + const pooled: BidSubmissionResult = { success: false, pending: true, error: "timed out in the pool" } + expect((await venuesWith({ hyperbridge: pooled }).venues.place(COMMITMENT, USER_OP, BID)).hyperbridge).toBe(pooled) + + const failed = venuesWith({ hyperbridge: { success: false, error: "Transaction failed after 3 attempts" } }) + await failed.venues.place(COMMITMENT, USER_OP, BID) + expect(failed.gate.spent).not.toHaveBeenCalled() + expect(failed.gate.exhausted).not.toHaveBeenCalled() + }) + + it("renders an orderbook request that got no answer as a failed submission, keeping Hyperbridge's", async () => { + const { venues } = venuesWith({ orderbook: new Error("Could not reach the orderbook") }) + + const placed = await venues.place(COMMITMENT, USER_OP, BID) + + expect(placed.hyperbridge).toEqual(LANDED) + expect(placed.orderbook).toEqual({ + kind: "failed", + code: "REQUEST_FAILED", + message: "Could not reach the orderbook", + retryable: true, + }) + }) + + it("passes the orderbook's refusal through", async () => { + const rejected: SubmitBidResult = { kind: "rejected", code: "NO_LIMIT_ORDER", message: "no limit order on the book" } + const { venues } = venuesWith({ orderbook: rejected }) + + expect((await venues.place(COMMITMENT, USER_OP, BID)).orderbook).toBe(rejected) + }) + + it("without a Hyperbridge connection posts to the orderbook alone, without a word about balance", async () => { + const warnings = logger.warn.mock.calls.length + const postBid = vi.fn(async () => ACCEPTED) + const venues = new BidVenues({ orderbook: { submitBid: postBid } as never }, logger as never) + + expect(await venues.place(COMMITMENT, USER_OP, BID)).toEqual({ orderbook: ACCEPTED }) + expect(logger.warn.mock.calls.length).toBe(warnings) + }) +}) + +describe("BridgeBalanceGate", () => { + const DEPOSIT = 1_000n + const EXISTENTIAL = 100n + const FEE = 10n + const COST = DEPOSIT + EXISTENTIAL + FEE + + /** A Hyperbridge whose account holds `free`, with the pallet's deposit as a constant or set on chain. */ + function hyperbridgeWith(state: { free: bigint; storedDeposit?: bigint; failReads?: boolean }) { + const reads = { account: 0 } + const api = { + query: { + system: { + account: async () => { + reads.account++ + if (state.failReads) throw new Error("node away") + return { data: { free: { toString: () => state.free.toString() } } } + }, + }, + intentsCoprocessor: { + storageDepositFee: async () => ({ toString: () => (state.storedDeposit ?? 0n).toString() }), + }, + }, + consts: { + intentsCoprocessor: { storageDepositFee: { toString: () => DEPOSIT.toString() } }, + balances: { existentialDeposit: { toString: () => EXISTENTIAL.toString() } }, + }, + tx: { + intentsCoprocessor: { + placeBid: () => ({ paymentInfo: async () => ({ partialFee: { toString: () => FEE.toString() } }) }), + }, + }, + } + const hyperbridge = { queryApi: async () => api, getKeyPair: () => ({ address: "5Filler" }) } as never + return { hyperbridge, reads } + } + + it("lets a bid through while the free balance covers the deposit, the existential deposit and the fee", async () => { + let now = 1_000_000 + const { hyperbridge, reads } = hyperbridgeWith({ free: COST }) + const gate = new BridgeBalanceGate(hyperbridge, logger as never, () => now) + + expect(await gate.canPlaceBid()).toBe(true) + expect(gate.freeBalance).toBe(COST) + // One bid spent: the next read has not seen it, so the reading is lowered here. + gate.spent() + expect(await gate.canPlaceBid()).toBe(false) + expect(reads.account).toBe(1) + + // The reading stands for its TTL, then is read again. + now += BRIDGE_BALANCE_TTL_MS + 1 + expect(await gate.canPlaceBid()).toBe(true) + expect(reads.account).toBe(2) + }) + + it("prefers the deposit set on chain over the runtime constant", async () => { + const { hyperbridge } = hyperbridgeWith({ free: COST, storedDeposit: DEPOSIT * 2n }) + expect(await new BridgeBalanceGate(hyperbridge, logger as never).canPlaceBid()).toBe(false) + }) + + it("holds bids back after Hyperbridge refused one for want of balance, until the next read", async () => { + let now = 1_000_000 + const { hyperbridge } = hyperbridgeWith({ free: COST * 10n }) + const gate = new BridgeBalanceGate(hyperbridge, logger as never, () => now) + expect(await gate.canPlaceBid()).toBe(true) + + gate.exhausted() + expect(await gate.canPlaceBid()).toBe(false) + now += BRIDGE_BALANCE_TTL_MS + 1 + expect(await gate.canPlaceBid()).toBe(true) + }) + + it("lets bids through to Hyperbridge when the balance cannot be read", async () => { + const { hyperbridge } = hyperbridgeWith({ free: 0n, failReads: true }) + expect(await new BridgeBalanceGate(hyperbridge, logger as never).canPlaceBid()).toBe(true) + }) + + it("reads once for concurrent bids", async () => { + const { hyperbridge, reads } = hyperbridgeWith({ free: COST * 10n }) + const gate = new BridgeBalanceGate(hyperbridge, logger as never) + await Promise.all([gate.canPlaceBid(), gate.canPlaceBid(), gate.canPlaceBid()]) + expect(reads.account).toBe(1) + }) +}) diff --git a/sdk/packages/simplex/src/tests/core/orderbook-bids.test.ts b/sdk/packages/simplex/src/tests/core/orderbook-bids.test.ts new file mode 100644 index 0000000000..9f48c33f99 --- /dev/null +++ b/sdk/packages/simplex/src/tests/core/orderbook-bids.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest" +import { bytes20ToBytes32, type HexString } from "@hyperbridge/sdk" +import { IntentFiller } from "@/core/filler" +import { MemoryDataStore } from "@/data/memory" +import type { SubmitBidResult } from "@/orderbook/types" +import { CacheService } from "@/services/CacheService" +import type { FillResult } from "@/strategies/base" +import { stubOrderScanner } from "../helpers/stub-scanner" +import { fakeClient, limitOrderService, limitOrderStore, ORDERBOOK_FIXTURES } from "../helpers/limit-orders" + +/** + * A bid the orderbook holds is a bid a swapper can execute, whatever Hyperbridge said: a filler + * without BRIDGE for the deposit bids on the orderbook alone. Such a bid is recorded as live + * there, counted as a placed bid, left alone by the retraction sweep (there is no deposit to + * reclaim and the orderbook serves it until its TTL), and a fill it wins is settled against the + * limit order that priced it like any other. + */ + +const { CHAIN, USDC, ONE } = ORDERBOOK_FIXTURES +const OUR_ADDRESS = "0xAAAA00000000000000000000000000000000AAAA" as HexString +const COMMITMENT = `0x${"c0".repeat(32)}` as HexString +const BID = `0x${"b1".repeat(32)}` as HexString +const LIMIT_ORDER = "3f2b8c1e-9d4a-4f6b-8a7c-5e1d2c3b4a59" +const DECIMALS = 6 +const UNIT = 10n ** BigInt(DECIMALS) + +const ACCEPTED: SubmitBidResult = { + kind: "accepted", + bid: { + commitment: COMMITMENT, + leg: 0, + side: "ASK", + fillChain: CHAIN, + sourceChain: CHAIN, + price: (1500n * ONE).toString(), + amountIn: (375_000n * ONE).toString(), + amountOut: (250n * ONE).toString(), + expiresAt: "2026-10-21T00:10:00Z", + }, +} + +describe("a bid the orderbook holds", () => { + /** A filler whose strategy answers every order with `result`. */ + function fillerWith(result: Omit) { + const cache = new CacheService() + const strategy = { + name: "test", + canFill: async () => true, + calculateProfitability: async () => 1, + executeOrder: async () => ({ ...result, commitment: COMMITMENT, bid: BID }), + getOrderUsdValue: async () => ({ inputUsd: { toNumber: () => 1 } }), + } + const bids = new MemoryDataStore().bids + const filler = new IntentFiller( + [{ chainId: 1 } as never], + [strategy as never], + { maxConcurrentOrders: 1 } as never, + { + getHyperbridgeWsUrl: () => undefined, + getSubstratePrivateKey: () => undefined, + getIntentGatewayAddress: () => "0xGATE", + getRpcUrls: () => ["https://rpc.example"], + } as never, + {} as never, + { cacheService: cache } as never, + { address: OUR_ADDRESS } as never, + { orders: stubOrderScanner([1]) }, + undefined, + bids, + ) + const executed: { success: boolean; error?: string }[] = [] + filler.monitor.on("orderExecuted", (event: { success: boolean; error?: string }) => executed.push(event)) + const order = { id: "0xorder", source: "EVM-1", destination: "EVM-1" } + const run = async () => { + ;(filler as any).executeOrder(order, strategy, true, { toNumber: () => 1 }, 1) + await (filler as any).chainQueues.get(1).onIdle() + } + return { bids, executed, run } + } + + it("is stored as live on the orderbook, counted as placed, and left to expire there", async () => { + const { bids, executed, run } = fillerWith({ success: false, orderbook: ACCEPTED }) + await run() + + const row = await bids.byCommitment(COMMITMENT) + expect(row).toMatchObject({ bid: BID, success: false, pending: false, onOrderbook: true, retracted: false }) + expect(executed).toEqual([expect.objectContaining({ success: true, error: undefined })]) + // No deposit on Hyperbridge, so nothing for the sweep to reclaim. + expect(await bids.expiredUnretracted(0)).toEqual([]) + expect(await bids.unretractedReclaimable()).toEqual([]) + }) + + it("records a bid Hyperbridge took as well, with the deposit to reclaim", async () => { + const { bids, run } = fillerWith({ success: true, txHash: "0xext", orderbook: ACCEPTED }) + await run() + + expect(await bids.byCommitment(COMMITMENT)).toMatchObject({ success: true, onOrderbook: true, extrinsicHash: "0xext" }) + expect(await bids.unretractedReclaimable()).toHaveLength(1) + }) + + it("is a failed bid when the orderbook refused it and Hyperbridge did not take it", async () => { + const { bids, executed, run } = fillerWith({ + success: false, + error: "Dispatch error: intentsCoprocessor::InsufficientBalance", + orderbook: { kind: "rejected", code: "NO_LIMIT_ORDER", message: "no limit order" }, + }) + await run() + + expect(await bids.byCommitment(COMMITMENT)).toMatchObject({ success: false, onOrderbook: false }) + expect(executed).toEqual([ + expect.objectContaining({ success: false, error: "Dispatch error: intentsCoprocessor::InsufficientBalance" }), + ]) + }) + + it("settles the fill it wins against the limit order that priced it", async () => { + const data = new MemoryDataStore() + const limitOrders = await limitOrderStore([ + { id: LIMIT_ORDER, base: "USDC", quote: "CNGN", side: "ASK", fillChain: CHAIN, price: "1500", size: "1000" }, + ]) + const { service } = limitOrderService(fakeClient([]), limitOrders) + const charged = 250n + ;(service as any).contractService.limitOrderSpent = async () => charged * UNIT + + const filler = new IntentFiller( + [], + [], + {}, + { + getHyperbridgeWsUrl: () => undefined, + getSubstratePrivateKey: () => undefined, + getBidValiditySeconds: () => 300, + getSimplexPaymasterAddress: () => "0x0000000000000000000000000000000000000001", + } as any, + {} as any, + {} as any, + { address: OUR_ADDRESS } as any, + { orders: stubOrderScanner() }, + undefined, + data.bids, + limitOrders, + ) as any + filler.assetRegistry = { getAddress: () => USDC } + filler.contractService = { getTokenDecimals: async () => DECIMALS } + filler.setLimitOrderService(service) + + // Posted to the orderbook alone: no BRIDGE for the deposit. + await data.bids.store({ + commitment: COMMITMENT, + bid: BID, + success: false, + onOrderbook: true, + pricedBy: { limitOrderId: LIMIT_ORDER, payout: (charged * ONE).toString(), take: (charged * 1500n * ONE).toString() }, + }) + + filler.monitor.emit("orderFilledOnChain", { + commitment: COMMITMENT, + filler: OUR_ADDRESS, + chainId: 8453, + outputs: [{ token: bytes20ToBytes32(USDC), amount: charged * UNIT }], + inputs: [{ token: bytes20ToBytes32(ORDERBOOK_FIXTURES.CNGN), amount: charged * 1500n * ONE }], + complete: true, + }) + await filler.settlementQueue.onIdle() + + const fills = await limitOrders.fills(LIMIT_ORDER) + expect(fills).toHaveLength(1) + expect(fills[0]).toMatchObject({ commitment: COMMITMENT, bid: BID, amount: (charged * ONE).toString() }) + expect((await limitOrders.get(LIMIT_ORDER))!.remaining).toBe((750n * ONE).toString()) + }) +}) diff --git a/sdk/packages/simplex/src/tests/fixtures/orderbook-schema.graphql b/sdk/packages/simplex/src/tests/fixtures/orderbook-schema.graphql index f3465e5a98..6864453e2b 100644 --- a/sdk/packages/simplex/src/tests/fixtures/orderbook-schema.graphql +++ b/sdk/packages/simplex/src/tests/fixtures/orderbook-schema.graphql @@ -98,6 +98,96 @@ enum BarInterval { D1 } +type Bid { + solver: Solver! + """ + The swapper's order this bid fills: `keccak256(abi.encode(order))`. + """ + commitment: Bytes! + """ + Which of the order's legs the bid quotes, from 0: `order.inputs[leg]` for + `order.output.assets[leg]`. The bid declines the order's other legs; a solver fills each + leg with a bid of its own. Most orders carry one leg. + """ + leg: Int! + book: Book! + """ + The side of the book the bid is on: BID where the solver buys the base, ASK where it sells + it. + """ + side: Side! + """ + The order's destination, where the bid executes and `tokenOut` is delivered. + """ + fillChain: String! + """ + The order's source, where the swapper's `tokenIn` is escrowed. + """ + sourceChain: String! + """ + What the solver takes: the swapper's `tokenIn`. + """ + tokenIn: String! + """ + What the solver pays out: the swapper's `tokenOut`. + """ + tokenOut: String! + """ + The rate the bid signed, quote per 1 base (1e18), before the protocol fee: `amountOut` over + `amountIn`, rounded against the swapper. Reported as signed, not held to the solver's limit + orders. + """ + price: BigInt! + """ + The input the bid takes of the leg (1e18 `tokenIn`): `options.inputs[leg].amount`. + """ + amountIn: BigInt! + """ + What the solver pays for that take (1e18 `tokenOut`), before the protocol fee: + `options.outputs[leg].amount`. + """ + amountOut: BigInt! + receivedAt: DateTime! + """ + When the server stops serving the bid: `receivedAt` plus `serverInfo.bidTtlSecs`. The op's + own `validUntil` is a block number on `fillChain`, which the gateway checks and this server + does not. + """ + expiresAt: DateTime! + """ + The signed UserOp, SCALE-encoded as it was submitted: what the swapper executes. + """ + userOp: Bytes! +} + +""" +The bid was stored, and is served until its TTL is up. +""" +type BidAccepted { + bid: Bid! +} + +type BidRejected { + code: RejectionCode! + message: String! +} + +""" +The server could not decide the bid: the database, or the submission running out of time. +""" +type BidSubmissionFailed { + code: FailureCode! + message: String! + retryable: Boolean! +} + +""" +The op was accepted before and its bid is still live, so nothing changed. +""" +type BidUnchanged { + bid: Bid! +} + scalar BigInt type Book { @@ -130,8 +220,8 @@ type Book { levels(side: Side!, fillChain: String, sourceChain: String): [PriceLevel!]! """ True liquidity behind the side's surfaced orders matching fillChain and sourceChain: per - distinct (solver, fill chain), its largest validated order among them, summed (1e18, in the - token the side pays out). Unvalidated orders add nothing. `sourceChain` matches as in + distinct (solver, fill chain), what its balance backs of its validated orders among them, + their sum as far as the balance covers it, summed (1e18, in the token the side pays out). Unvalidated orders add nothing. `sourceChain` matches as in `levels`: orders whose own declaration lists it. """ availableLiquidity(side: Side!, fillChain: String, sourceChain: String): BigInt! @@ -212,11 +302,13 @@ type BookHistoryPoint { """ askDepthQuote: BigInt! """ - True: each (solver, fill chain)'s largest validated surfaced bid, summed, in quote (1e18). + True: what each (solver, fill chain)'s balance backs of its validated surfaced bids, summed, + in quote (1e18). """ bidLiquidity: BigInt! """ - True: each (solver, fill chain)'s largest validated surfaced ask, summed, in base (1e18). + True: what each (solver, fill chain)'s balance backs of its validated surfaced asks, summed, + in base (1e18). """ askLiquidity: BigInt! bidOrders: Int! @@ -298,23 +390,26 @@ type LevelQuote { amountIn: BigInt! """ The tokenOut the swapper receives at `rate` (1e18), after destinationChain's protocol fee, - floored to a whole raw unit there; 0 when not fillable. + floored to a whole raw unit there. When not fillable, what the whole amountIn would receive + at the worst level's price, which the route cannot deliver. """ amountOut: BigInt! """ The worst single-order price in the level that fills it, quote per 1 base (1e18), before the protocol fee: every order in the level, or on the route when no one level can, fills at - it. Null when the route's orders together cannot fill the trade. + it. When not fillable, the route's worst single-order price. Null only when no order serves + the route. """ rate: BigInt """ - The price bucket of that level: the route's worst level when no one level fills it. Null - when not fillable. + The price bucket of that level: the route's worst level when no one level fills it, or when + not fillable. Null only when no order serves the route. """ priceBucket: BigInt """ - destinationChain's protocol fee, in basis points: the least slippage from `rate` the swapper - should allow for. `amountOut` already has it taken off. + The least slippage the swapper should allow for, in basis points: destinationChain's protocol + fee plus how far `rate` sits from the route's best price, against the swapper, rounded up. + `amountOut` already has the fee taken off. """ slippageBps: Int! fillable: Boolean! @@ -350,6 +445,19 @@ type Mutation { """ submitOrders(userOps: [Bytes!]!): [SubmitOrderResult!]! """ + Post a bid on one leg of a swapper's order: the signed, executable fillOrder UserOp the + solver would have sent to the coprocessor, SCALE-encoded. It is verified as an order is, + except that it need not be phantom, carries no TTL and declares nothing. It quotes exactly + one of the order's legs, a positive take and quote, and declines the rest with zeros; a + solver fills the other legs with bids of their own. Its rate is `outputs[leg].amount` over + `inputs[leg].amount`, reported as signed. It is accepted only from a solver that is active + and holds an active limit order on that leg's book filling on the order's destination + chain (`NO_LIMIT_ORDER`); a solver may post any number of bids on one order. It is served under the order's commitment for + `serverInfo.bidTtlSecs`, then deleted; re-sending it meanwhile is `BidUnchanged`, and + afterwards posts it afresh. Counts one op against the request's batch budget. + """ + submitBid(userOp: Bytes!): SubmitBidResult! + """ Cancel one of the signer's orders. Signs EIP-712 CancelOrder(solver, commitment, timestamp). """ cancelOrder(solver: Address!, commitment: Bytes!, timestamp: Int!, signature: Bytes!): CancelOrderResult! @@ -591,8 +699,11 @@ type Query { """ The optimistic quote for an input amount (1e18): the route's orders, best price first, each taking what it can of the trade at its own price, on either route kind. Each fill says the - tokenIn it takes and the tokenOut it delivers after destinationChain's protocol fee, which - `slippageBps` reports; there is no total output. + tokenIn it takes and the tokenOut it delivers after destinationChain's protocol fee; there is + no total output. `slippageBps` is the fee plus the fills' average price's distance from the + route's best price. When best first falls short but `quotePessimistic` fills the trade, this + fills it too, from that quote's level at its price: a solver's cap spent on its better, + smaller orders never leaves a trade its larger order could take unfillable. """ quote(route: RouteInput!, amountIn: BigInt!): SwapQuote! """ @@ -601,7 +712,8 @@ type Query { single-order price, at which every order in it fills. Levels better than it do not add to its depth. When no one level can, but the route's levels together can, it serves the route's worst price; it is unfillable only when they together cannot. `amountOut` has - destinationChain's protocol fee taken off, and `slippageBps` reports it. + destinationChain's protocol fee taken off. `slippageBps` is the fee plus the rate's distance + from the route's best price. """ quotePessimistic(route: RouteInput!, amountIn: BigInt!): LevelQuote! """ @@ -621,6 +733,15 @@ type Query { """ order(solver: Address!, commitment: Bytes!): Order """ + The live bids on the swapper's order under `commitment`, leg by leg and, within a leg, best + for the swapper first: the highest price where the solvers buy the base (a BID side), the + lowest where they sell it (an ASK side), and the oldest among equals. Each bid quotes one + leg; `leg` keeps that leg's alone, and `solver` one solver's. Every bid is from a solver + with an active limit order at that price; whether the order is still open on chain is the + swapper's to check. + """ + bids(commitment: Bytes!, leg: Int, solver: Address): [Bid!]! + """ Solver balances, oldest row first, with the totals per chain and symbol of every row the filter covers. """ @@ -665,8 +786,8 @@ type QuoteFill { """ advertisedSize: BigInt! """ - The order's own price, quote per 1 base (1e18), before the protocol fee: what this fill - settles at. + What this fill settles at, quote per 1 base (1e18), before the protocol fee: the order's own + price, or the pessimistic quote's level price when the quote falls back to that level. """ orderRate: BigInt! """ @@ -777,7 +898,8 @@ type RateEntry { """ depthOut: BigInt! """ - True: each distinct solver's largest validated order in the level, in tokenOut (1e18). + True: what each distinct solver's balance backs of its validated orders in the level, their + sum as far as the balance covers it, summed across solvers, in tokenOut (1e18). """ backingLiquidity: BigInt! """ @@ -808,7 +930,8 @@ enum RateSeries { """ Everything ingest can decide without a chain read. Delegation and liquidity are absent: the -validation cycle settles both on a stored order. +validation cycle settles both on a stored order. The last two are a bid's alone: a bid is held +to the solver's standing on the book rather than to a TTL or a declaration. """ enum RejectionCode { MALFORMED_USER_OP @@ -827,6 +950,8 @@ enum RejectionCode { ORDER_EXISTS TOO_MANY_ORDERS MIN_ORDER_SIZE + NO_LIMIT_ORDER + TOO_MANY_BIDS } """ @@ -890,13 +1015,13 @@ type RouteLiquidity { """ depthOut: BigInt! """ - True: each distinct serving solver's largest validated serving order, in tokenOut on the + True: what each distinct serving solver's balance backs of its validated serving orders, + their sum as far as the balance covers it, summed across solvers, in tokenOut on the destination (1e18). """ availableLiquidity: BigInt! """ - The largest amountIn the route's orders could take together, each at its own price, as - `quote` fills them (1e18 tokenIn, whole raw units). + The largest amountIn `quote` could fill (1e18 tokenIn, whole raw units). """ maxFillableIn: BigInt! orderCount: Int! @@ -924,11 +1049,19 @@ type ServerInfo { """ signatureSkewSecs: Int! """ - Most UserOps one request may place, counted across all its `submitOrder` and - `submitOrders` fields. + Most UserOps one request may place, counted across all its `submitOrder`, `submitOrders` + and `submitBid` fields. """ maxBatchSize: Int! """ + How long a bid on a swapper's order is served, in seconds from receipt. + """ + bidTtlSecs: Int! + """ + Live bids one solver may hold. + """ + maxBidsPerSolver: Int! + """ Tokens with a dust floor: an order paying out less than this is rejected. """ minOrderSizes: [TokenMinSize!]! @@ -999,9 +1132,10 @@ type SolverBalance { """ total: BigInt! """ - True liquidity (1e18): the largest validated output among all the solver's live orders - paying this token on this chain; 0 when it has no validated order. What it has offered to - deliver; no more than `total` as of the last refresh. + True liquidity (1e18): what `total` backs of the solver's validated live orders paying this + token on this chain, their summed sizes when it covers them all and `total` when it does + not; 0 when it has no validated order. What it has offered to deliver, as of the last + refresh. """ liquidity: BigInt! """ @@ -1038,6 +1172,8 @@ enum SolverStatus { SUSPENDED } +union SubmitBidResult = BidAccepted | BidUnchanged | BidRejected | BidSubmissionFailed + union SubmitOrderResult = OrderAccepted | OrderUnchanged | OrderRejected | OrderSubmissionFailed """ @@ -1055,18 +1191,23 @@ type SwapQuote { """ amountIn: BigInt! """ - destinationChain's protocol fee, in basis points: the least slippage from each fill's - orderRate the swapper should allow for. Every fill's amountOut already has it taken off. + The least slippage the swapper should allow for, in basis points: destinationChain's protocol + fee plus how far the fills' average price (weighted by the base each trades) sits from the + route's best price, against the swapper, rounded up. Every fill's amountOut already has the + fee taken off. """ slippageBps: Int! fillable: Boolean! """ - The largest amountIn the route's orders could take together, each at its own price (1e18 - tokenIn, whole raw units). + The largest amountIn this quote could fill (1e18 tokenIn, whole raw units): the route's + orders together, each at its own price, or the pessimistic quote's level at its price, + whichever is more. """ maxFillableIn: BigInt! """ - The orders used, best price first; empty when not fillable. + The orders used, best price first. When not fillable, one fill from the route's worst level + instead: the whole amountIn at the worst order's price, which the route cannot deliver. + Empty only when no order serves the route. """ fills: [QuoteFill!]! } diff --git a/sdk/packages/simplex/ui/src/operator/Orders.tsx b/sdk/packages/simplex/ui/src/operator/Orders.tsx index fbd46fd95d..a661230cb4 100644 --- a/sdk/packages/simplex/ui/src/operator/Orders.tsx +++ b/sdk/packages/simplex/ui/src/operator/Orders.tsx @@ -96,12 +96,24 @@ function clockOf(at: string): string { /** * The order's latest bid as two explorer links: up for the bid extrinsic, down * for its retraction. Re-bids after a retraction replace earlier ones. A failed - * bid has no extrinsic to link, so it shows as text with the error on hover. + * bid has no extrinsic to link, so it shows as text with the error on hover. A + * bid the orderbook holds that Hyperbridge did not take (no BRIDGE for the + * deposit, or a refusal) has nothing to link either, and says where it is. */ function BidLinks(props: { bid: BidDto | undefined; explorer: string }) { const { bid, explorer } = props if (!bid) return if (!bid.success) { + if (bid.onOrderbook) { + return ( + + Orderbook + + ) + } return ( Failed From d228489e3cead198962148545e6a8d3640935862 Mon Sep 17 00:00:00 2001 From: David Salami Date: Wed, 21 Oct 2026 17:21:10 +0000 Subject: [PATCH 2/4] [simplex]: e2e scenario for a bid the orderbook alone serves `orderbook-only-bid` restarts solver 1 on an unfunded Hyperbridge account before its book is posted, so its bid can only reach the orderbook, and passes when the order is filled by solver 1 from a bid the orderbook served and Hyperbridge never held. The swap worker points the SDK at the orderbook under test and records which solvers each venue serves whenever bids arrive. --- .../changelog/2026-09-22-testnet-swap-e2e.md | 14 +- .../2026-10-21-bids-on-the-orderbook.md | 8 ++ sdk/packages/simplex/e2e/env.mjs | 16 ++- sdk/packages/simplex/e2e/run.mjs | 135 +++++++++++++----- sdk/packages/simplex/e2e/swap.mjs | 24 +++- 5 files changed, 160 insertions(+), 37 deletions(-) diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-09-22-testnet-swap-e2e.md b/sdk/packages/simplex/docs/ai/changelog/2026-09-22-testnet-swap-e2e.md index dbb2302af8..f5028a4472 100644 --- a/sdk/packages/simplex/docs/ai/changelog/2026-09-22-testnet-swap-e2e.md +++ b/sdk/packages/simplex/docs/ai/changelog/2026-09-22-testnet-swap-e2e.md @@ -25,6 +25,7 @@ not exceed the order's size. A tally that cannot be read is logged and does not | `multi-leg-levels` | both pairs, solver 1's levels only | | `multi-leg-same-input` | two 5.5 USDC → cNGN legs against a level that takes 10 | | `onchain-limit` | 0.5 USDC for cNGN on BSC Chapel, then the checks of the on-chain limit | +| `orderbook-only-bid` | the same swap, with solver 1 running on a Hyperbridge account holding no BRIDGE | | `onchain-limit-refusal` | the same swap, with the best bid's limit order left one unit short of what it pays | `multi-leg` and `multi-leg-levels` put two pairs in one order, which #1311 forbids (`mixedPairs`). @@ -58,6 +59,17 @@ exactly what it approved. The runner then reads from the chain that: Both scenarios fail whatever their outcome when a solver's account on the fill chain is not delegated to an implementation with `spent`, and the reason names what the account delegates to. +`orderbook-only-bid` (`check: "orderbook"`, `noBridge: 0`) covers a solver bidding without BRIDGE. +Before its book is posted, the runner restarts solver 1 on a Hyperbridge account generated for the +run and never funded (`substratePrivateKey` alone changes; the data directory and EVM key stay), +so simplex finds no BRIDGE for the bid deposit and posts its bid to the orderbook alone, while +solvers 2 and 3 bid through Hyperbridge as usual. The swap worker points the SDK at the orderbook +under test (`withOrderbook`), and whenever bids arrive reads `intents_getBidsForOrder` and the +orderbook's `bids(commitment)` to record which solvers each venue serves (`result.venues`, also a +column of the summary). The scenario passes when the order is filled, solver 1's address is among +the orderbook's bids and not among Hyperbridge's, and a fill is solver 1's. Solver 1 is restarted on +its own key before the next scenario, whatever the outcome. + Pass scenario names as arguments, or through `E2E_SCENARIOS` (comma-separated, spaces allowed), to run a subset. Without names, every scenario except the `mixedPairs` ones runs. @@ -67,7 +79,7 @@ The orderbook refuses a limit order paying out under 10 USDC or 15000 cNGN Swap sizes are the scenarios' own business, and most take a fraction of one limit order: 0.5 USDC, 0.4 USDC, 2000 cNGN. Only the ladder scenarios are larger, because reaching a second level means -swapping more than the first level's 10 USDC. A default run spends 24.4 USDC on BSC Chapel and +swapping more than the first level's 10 USDC. A default run spends 24.9 USDC on BSC Chapel and 20000 cNGN on Polygon Amoy. ## Wallets diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-10-21-bids-on-the-orderbook.md b/sdk/packages/simplex/docs/ai/changelog/2026-10-21-bids-on-the-orderbook.md index a32f019dab..e5c5241c9d 100644 --- a/sdk/packages/simplex/docs/ai/changelog/2026-10-21-bids-on-the-orderbook.md +++ b/sdk/packages/simplex/docs/ai/changelog/2026-10-21-bids-on-the-orderbook.md @@ -65,3 +65,11 @@ Hyperbridge copy wins a tie since it carries the deposit. One source failing lea bids; both failing throws. `FillerBid.venue` is `"orderbook"` for a bid read there (its `filler` is the solver's address, `deposit` is 0). `IntentGateway.executeOrder` no longer requires a coprocessor, and `withOrderbook` now also redirects where bids are read from. + +## End to end + +The testnet swaps run gains `orderbook-only-bid`: the `same-chain` swap with solver 1 restarted on +an unfunded Hyperbridge account, so its bid can only reach the orderbook. The swap worker reads +the SDK's bids from the orderbook under test and records which solvers each venue served; the +scenario passes only when solver 1's bid was on the orderbook, not on Hyperbridge, and filled the +order. See [the e2e note](2026-09-22-testnet-swap-e2e.md). diff --git a/sdk/packages/simplex/e2e/env.mjs b/sdk/packages/simplex/e2e/env.mjs index 975a0d51a1..3f506f15b7 100644 --- a/sdk/packages/simplex/e2e/env.mjs +++ b/sdk/packages/simplex/e2e/env.mjs @@ -107,7 +107,9 @@ export const EXTRA_LEVELS = { * Each scenario runs against a freshly posted book: the standing orders, plus the extra * solver 1 levels it names. `minFills` is the fewest fill transactions it must take. A scenario * with `check: "limit"` or `check: "refusal"` must also pass the checks of the on-chain limit in - * e2e/budget.mjs. + * e2e/budget.mjs. One with `noBridge` runs that solver (by index) on a Hyperbridge account holding + * no BRIDGE, so its bids can only reach the orderbook; `check: "orderbook"` then requires the + * order to be filled by that solver from a bid the orderbook served and Hyperbridge never held. * * `mixedPairs` scenarios put two pairs in one order, which #1311 forbids. They pass only against * a gateway without that rule, so they run only when named. @@ -195,6 +197,18 @@ export const SCENARIOS = { legs: [{ tokenIn: "USDC", amountIn: "0.5", tokenOut: "cNGN", minOut: "778.5" }], minFills: 1, }, + // The `same-chain` swap with solver 1, the best priced, holding no BRIDGE: its bid cannot reach + // Hyperbridge and goes to the orderbook alone, where the SDK reads it beside the others' bids + // and executes it first. The other two solvers bid through Hyperbridge as usual. + "orderbook-only-bid": { + check: "orderbook", + noBridge: 0, + user: 0, + source: "EVM-97", + dest: "EVM-97", + legs: [{ tokenIn: "USDC", amountIn: "0.5", tokenOut: "cNGN", minOut: "778.5" }], + minFills: 1, + }, // The `same-chain` swap once more. Before the user picks a bid, the best bid's solver uses up // its limit order's room on chain until it is one unit short of what the bid pays. The bid must // then fail the SDK's simulation, revert on chain with `LimitOrderExceeded` when it is sent diff --git a/sdk/packages/simplex/e2e/run.mjs b/sdk/packages/simplex/e2e/run.mjs index bdb5d95eb8..c7259bf835 100644 --- a/sdk/packages/simplex/e2e/run.mjs +++ b/sdk/packages/simplex/e2e/run.mjs @@ -13,7 +13,8 @@ import os from "node:os" import path from "node:path" import { fileURLToPath } from "node:url" import { ApiPromise, Keyring, WsProvider } from "@polkadot/api" -import { cryptoWaitReady, keccakAsU8a } from "@polkadot/util-crypto" +import { u8aToHex } from "@polkadot/util" +import { cryptoWaitReady, keccakAsU8a, randomAsU8a } from "@polkadot/util-crypto" import { createPublicClient, createWalletClient, erc20Abi, formatEther, formatUnits, http, parseUnits } from "viem" import { privateKeyToAccount } from "viem/accounts" import { checkBudgets, keep } from "./budget.mjs" @@ -59,7 +60,16 @@ const solverKeys = [ { key: env.solver3Key, substrate: env.solver3Substrate }, ] -function solverConfig(index) { +/** + * A Hyperbridge account nobody has funded, for the solver a `noBridge` scenario runs without + * BRIDGE: it can connect and read, but not pay for a bid, so its bids reach the orderbook alone. + * Fresh each run, and redacted like a real key. + */ +const UNFUNDED_SUBSTRATE_KEY = u8aToHex(randomAsU8a(32)) +SECRETS.unshift(UNFUNDED_SUBSTRATE_KEY) + +/** @param substrate - The Hyperbridge key the solver bids with; its own unless a scenario withholds it. */ +function solverConfig(index, substrate = solverKeys[index].substrate) { const q = JSON.stringify return `[simplex] # The scenarios exercise partial-fill mechanics with testnet amounts below the production floor. @@ -67,7 +77,7 @@ minPartialFillUsd = 0 maxConcurrentOrders = 5 logging = "info" bidValiditySeconds = 600 -substratePrivateKey = ${q(solverKeys[index].substrate)} +substratePrivateKey = ${q(substrate)} hyperbridgeWsUrl = ${q(env.hyperbridge)} [simplex.signer] @@ -113,43 +123,85 @@ async function api(port, method, route, body) { return { status: response.status, ok: response.ok, json } } +/** The running process of each solver, by index. */ const processes = [] +function startSolver(index, substrate) { + const solver = SOLVERS[index] + const dir = path.join(workdir, solver.name) + fs.mkdirSync(path.join(dir, "data"), { recursive: true }) + const config = path.join(dir, "config.toml") + fs.writeFileSync(config, solverConfig(index, substrate), { mode: 0o600 }) + const out = fs.openSync(path.join(dir, "solver.log"), "a") + const child = spawn( + process.execPath, + [SIMPLEX, "run", "-c", config, "-d", path.join(dir, "data"), "--ui", `127.0.0.1:${solver.port}`, "--no-open", "--log-format", "json"], + { stdio: ["ignore", out, out] }, + ) + child.on("exit", (code, signal) => log(`${solver.name} exited (${code ?? signal})`)) + processes[index] = child + log(`${solver.name} started, pid ${child.pid}${substrate ? " on an unfunded Hyperbridge account" : ""}`) +} + function startSolvers() { - SOLVERS.forEach((solver, index) => { - const dir = path.join(workdir, solver.name) - fs.mkdirSync(path.join(dir, "data"), { recursive: true }) - const config = path.join(dir, "config.toml") - fs.writeFileSync(config, solverConfig(index), { mode: 0o600 }) - const out = fs.openSync(path.join(dir, "solver.log"), "a") - const child = spawn( - process.execPath, - [SIMPLEX, "run", "-c", config, "-d", path.join(dir, "data"), "--ui", `127.0.0.1:${solver.port}`, "--no-open", "--log-format", "json"], - { stdio: ["ignore", out, out] }, - ) - child.on("exit", (code, signal) => log(`${solver.name} exited (${code ?? signal})`)) - processes.push(child) - log(`${solver.name} started, pid ${child.pid}`) - }) + SOLVERS.forEach((_solver, index) => startSolver(index)) +} + +async function stopSolver(child) { + if (child.exitCode === null) child.kill("SIGTERM") + for (let i = 0; i < 20 && child.exitCode === null; i++) await pause(500) + if (child.exitCode === null) child.kill("SIGKILL") } async function stopSolvers() { - for (const child of processes) if (child.exitCode === null) child.kill("SIGTERM") - for (let i = 0; i < 20 && processes.some((child) => child.exitCode === null); i++) await pause(500) - for (const child of processes) if (child.exitCode === null) child.kill("SIGKILL") + await Promise.all(processes.map(stopSolver)) +} + +/** + * Stops one solver and starts it again on `substrate`, its own key when none is given. The + * data directory stays, so its limit orders are still its to withdraw when the book is reposted. + */ +async function restartSolver(index, substrate) { + await stopSolver(processes[index]) + startSolver(index, substrate) + await waitSolver(SOLVERS[index]) +} + +async function waitSolver(solver) { + const until = Date.now() + 180_000 + for (;;) { + const ready = await api(solver.port, "GET", "/api/limit-orders?status=open").catch(() => undefined) + if (ready?.ok) break + if (Date.now() > until) throw new Error(`${solver.name} never answered on port ${solver.port}`) + await pause(2_000) + } + log(`${solver.name} ready`) } async function waitReady() { - for (const solver of SOLVERS) { - const until = Date.now() + 180_000 - for (;;) { - const ready = await api(solver.port, "GET", "/api/limit-orders?status=open").catch(() => undefined) - if (ready?.ok) break - if (Date.now() > until) throw new Error(`${solver.name} never answered on port ${solver.port}`) - await pause(2_000) - } - log(`${solver.name} ready`) + for (const solver of SOLVERS) await waitSolver(solver) +} + +/** + * The checks of a `check: "orderbook"` scenario: the order was filled by the solver running + * without BRIDGE, from a bid the orderbook served and Hyperbridge never held. A solver that + * could still reach Hyperbridge, or whose bid the orderbook refused, fails it by name. + * Returns the reason it fails, or undefined. + */ +function orderbookChecks(scenario, result) { + const solver = SOLVERS[scenario.noBridge] + const address = SOLVER_ACCOUNTS[scenario.noBridge].address.toLowerCase() + const venues = result.venues + if (!venues) return `${solver.name}'s bid venues were never read` + if (venues.hyperbridge.includes(address)) return `${solver.name} holds no BRIDGE, yet Hyperbridge served its bid` + if (!venues.orderbook.includes(address)) { + return `the orderbook served no bid from ${solver.name}; it served ${venues.orderbook.length} from ${venues.orderbook.join(", ") || "nobody"}` + } + const filledBy = result.fills.map((fill) => fill.solver?.toLowerCase()) + if (!filledBy.includes(address)) { + return `${solver.name}'s orderbook bid was not executed; the order was filled by ${filledBy.join(", ") || "nobody"}` } + return undefined } async function liveOrders(solver) { @@ -421,13 +473,14 @@ function report(results) { const lines = [ "## Simplex testnet swaps", "", - "| Scenario | Outcome | Fills | Bids offered | Fill transactions |", - "| --- | --- | --- | --- | --- |", + "| Scenario | Outcome | Fills | Bids offered | Bids on Hyperbridge / orderbook | Fill transactions |", + "| --- | --- | --- | --- | --- | --- |", ] for (const r of results) { const bids = r.bidRounds?.[0]?.length ?? 0 + const venues = r.venues ? `${r.venues.hyperbridge.length} / ${r.venues.orderbook.length}` : "" const txs = r.fills.map((fill) => `\`${fill.transactionHash}\``).join("
") - lines.push(`| ${r.scenario} | ${r.passed ? "✅" : "❌"} ${r.outcome}${r.reason ? ` (${r.reason})` : ""} | ${r.fills.length} | ${bids} | ${txs} |`) + lines.push(`| ${r.scenario} | ${r.passed ? "✅" : "❌"} ${r.outcome}${r.reason ? ` (${r.reason})` : ""} | ${r.fills.length} | ${bids} | ${venues} | ${txs} |`) } const text = redact(lines.join("\n")) console.log(text) @@ -447,15 +500,31 @@ async function main() { log(`== ${name}`) let result try { + // A solver run without BRIDGE for this scenario alone; its own key is back before the next. + if (scenario.noBridge !== undefined) await restartSolver(scenario.noBridge, UNFUNDED_SUBSTRATE_KEY) await postBook(scenario.levels) result = await runScenario(name) } catch (error) { result = { scenario: name, outcome: "ERROR", error: String(error?.message ?? error), fills: [] } + } finally { + if (scenario.noBridge !== undefined) { + await restartSolver(scenario.noBridge).catch((error) => + log(`${SOLVERS[scenario.noBridge].name} could not be restarted on its own key: ${error?.message ?? error}`), + ) + } } result.scenario = name result.passed = result.outcome === "FILLED" && result.fills.length >= scenario.minFills if (result.outcome === "FILLED" && !result.passed) result.reason = `expected ${scenario.minFills}+ fills` if (result.error) result.reason = result.error.slice(0, 200) + if (scenario.check === "orderbook" && result.passed) { + const failure = orderbookChecks(scenario, result) + if (failure) { + log(`${name}: ${failure}`) + result.passed = false + result.reason = failure + } + } const breach = await checkBudgets({ clients, api, log }, scenario, result, book).catch((error) => { const failure = `the limit checks could not run: ${String(error?.message ?? error).slice(0, 200)}` if (scenario.check === "limit") return failure diff --git a/sdk/packages/simplex/e2e/swap.mjs b/sdk/packages/simplex/e2e/swap.mjs index cf2d93f572..f7ccce2298 100644 --- a/sdk/packages/simplex/e2e/swap.mjs +++ b/sdk/packages/simplex/e2e/swap.mjs @@ -4,7 +4,7 @@ import fs from "node:fs" import { createPublicClient, createWalletClient, erc20Abi, formatUnits, http, keccak256, maxUint256, parseUnits, toHex } from "viem" import { privateKeyToAccount } from "viem/accounts" -import { DEFAULT_GRAFFITI, EvmChain, IntentGateway, IntentsCoprocessor, bytes20ToBytes32 } from "@hyperbridge/sdk" +import { DEFAULT_GRAFFITI, EvmChain, HyperFxOrderbook, IntentGateway, IntentsCoprocessor, bytes20ToBytes32 } from "@hyperbridge/sdk" import { FEE_TOKEN, GATEWAY, HOST, SCENARIOS, TOKENS, chains, readEnv } from "./env.mjs" import { refuseBest } from "./refusal.mjs" @@ -65,7 +65,24 @@ async function main() { const sourceChain = EvmChain.fromParams({ chainId: src.id, host: HOST, rpcUrl: src.rpc }) const destChain = s.source === s.dest ? sourceChain : EvmChain.fromParams({ chainId: dst.id, host: HOST, rpcUrl: dst.rpc }) const coprocessor = await IntentsCoprocessor.connect(env.hyperbridge) - const gateway = await IntentGateway.create(sourceChain, destChain, coprocessor) + // The SDK reads bids from Hyperbridge and from the orderbook the solvers post to; by default + // it would read the network's public deployment, which need not be the one under test. + const orderbook = new HyperFxOrderbook(env.orderbook) + const gateway = (await IntentGateway.create(sourceChain, destChain, coprocessor)).withOrderbook(orderbook) + + /** + * Which solvers' bids each venue serves on the order, lower-cased senders. A solver with + * BRIDGE bids on both; one without reaches the orderbook alone. Read whenever bids arrive, + * since Hyperbridge's copy of a bid can land a few seconds after the orderbook's. + */ + const venues = { hyperbridge: new Set(), orderbook: new Set() } + async function readVenues(commitment) { + const [onHyperbridge, onOrderbook] = await Promise.allSettled([coprocessor.getBidsForOrder(commitment), orderbook.bids(commitment)]) + if (onHyperbridge.status === "fulfilled") for (const bid of onHyperbridge.value) venues.hyperbridge.add(bid.userOp.sender.toLowerCase()) + else log("venues-hyperbridge-unread", { error: String(onHyperbridge.reason?.message ?? onHyperbridge.reason) }) + if (onOrderbook.status === "fulfilled") for (const bid of onOrderbook.value) venues.orderbook.add(bid.solver.toLowerCase()) + else log("venues-orderbook-unread", { error: String(onOrderbook.reason?.message ?? onOrderbook.reason) }) + } const beneficiary = bytes20ToBytes32(user.address) const order = { @@ -131,6 +148,8 @@ async function main() { result.bidRounds.push( update.bids.map((bid) => ({ solver: bid.solverAddress, outputs: bid.outputs.map((output) => output.amount) })), ) + await readVenues(update.commitment) + log("venues", { hyperbridge: [...venues.hyperbridge], orderbook: [...venues.orderbook] }) // Once, on the first round: the SDK picks a bid only when the stream is resumed. if (s.check === "refusal" && !result.refusal) { result.refusal = await refuseBest({ @@ -167,6 +186,7 @@ async function main() { received[`${s.dest}:${symbol}`] = formatUnits(balance, token.decimals) } result.balancesAfter = received + result.venues = { hyperbridge: [...venues.hyperbridge], orderbook: [...venues.orderbook] } log("result", result) fs.writeFileSync(resultFile, json(result)) await coprocessor.disconnect().catch(() => {}) From 291a8cc10da4e08611ed91c79ae27975a37dd0b1 Mon Sep 17 00:00:00 2001 From: David Salami Date: Thu, 22 Oct 2026 05:58:49 +0000 Subject: [PATCH 3/4] [sdk]: pin pnpm@11.28.7 pnpm 11.28.7 applies the workspace's `trustPolicy: no-downgrade` to the version pinned in `packageManager` before switching to it. pnpm@11.0.0 was token-published after 10.33.x had been published through trusted publishing, so every CI install on a `version: 11` runner now fails in the install step with a trust downgrade. 11.28.7 is trusted-publisher again and a frozen install under it leaves the lockfile unchanged. --- sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/package.json b/sdk/package.json index 6e17975928..e2d9cad3cd 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -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" From 37d2dcf8b539ba1fe5d64afc2fb18dbaea392fc4 Mon Sep 17 00:00:00 2001 From: David Salami Date: Thu, 22 Oct 2026 11:00:38 +0000 Subject: [PATCH 4/4] [simplex, sdk]: review fixes for bids on the orderbook - The BRIDGE balance gate owns the "orderbook alone" warning, so it fires once per change of answer rather than per bid; a failed balance read stands for the TTL; the orderbook post no longer waits on the balance read; Substrate's generic 1010 no longer counts as an empty balance; the pallet's InsufficientBalance pays its fee, as the docs now say. - Bid stats and `failed()` count a bid the orderbook alone holds as placed. - A bid Hyperbridge refused but the orderbook holds logs at warn. - The SDK merges bids by userOpHash, skips an orderbook bid whose op does not decode, and documents the coprocessor as optional; the docs examples create the gateway without one; the retry result carries the last attempt's error. - `simplex.substratePrivateKey` is optional: without it the solver still delegates and bids on the orderbook alone; `simplex init` and the UI wizard allow skipping it. - e2e: solver 1 runs with no key at all in `orderbook-only-bid`, and `orderbook-only-swapper` runs the SDK without a coprocessor. - The changelog states what retraction guarantees now that an orderbook bid cannot be withdrawn. --- .../evm/intent-gateway/cancelling-orders.mdx | 7 +- .../evm/intent-gateway/placing-orders.mdx | 12 +-- .../sdk/src/chains/intentsCoprocessor.ts | 5 +- .../src/protocols/intents/IntentGateway.ts | 8 +- .../src/protocols/intents/OrderExecutor.ts | 37 +++++--- .../sdk/src/tests/bidSelection.test.ts | 43 +++++++++- .../tests/intentsCoprocessorPending.test.ts | 2 +- .../changelog/2026-09-22-testnet-swap-e2e.md | 29 ++++--- .../2026-10-21-bids-on-the-orderbook.md | 62 ++++++++----- sdk/packages/simplex/e2e/env.mjs | 27 ++++-- sdk/packages/simplex/e2e/run.mjs | 61 ++++++------- sdk/packages/simplex/e2e/swap.mjs | 6 +- sdk/packages/simplex/src/bin/simplex.ts | 9 +- .../simplex/src/cli/init/emit-toml.ts | 15 ++-- .../simplex/src/cli/init/help-text.ts | 2 +- .../simplex/src/cli/init/prompt-utils.ts | 9 +- .../simplex/src/cli/init/steps/hyperbridge.ts | 13 ++- .../simplex/src/cli/init/steps/write.ts | 9 +- .../simplex/src/config/filler-toml.ts | 18 ++-- sdk/packages/simplex/src/core/bid-venues.ts | 75 +++++++++------- sdk/packages/simplex/src/core/filler.ts | 4 +- sdk/packages/simplex/src/data/memory.ts | 11 ++- sdk/packages/simplex/src/data/sqlite/bids.ts | 6 +- sdk/packages/simplex/src/data/types.ts | 4 +- sdk/packages/simplex/src/strategies/fx.ts | 8 +- .../tests/cli/filler-toml-validate.test.ts | 12 +-- .../simplex/src/tests/core/bid-venues.test.ts | 76 ++++++++++++++-- .../src/tests/core/orderbook-bids.test.ts | 86 +++++++++++++++++-- .../simplex/src/tests/data/bid-store.test.ts | 11 ++- sdk/packages/simplex/ui/src/wizard/Wizard.tsx | 4 +- sdk/packages/simplex/ui/src/wizard/state.ts | 6 +- .../simplex/ui/src/wizard/steps/Substrate.tsx | 2 +- 32 files changed, 482 insertions(+), 197 deletions(-) diff --git a/docs/content/developers/evm/intent-gateway/cancelling-orders.mdx b/docs/content/developers/evm/intent-gateway/cancelling-orders.mdx index 6df66d56a2..b5d01b7c9e 100644 --- a/docs/content/developers/evm/intent-gateway/cancelling-orders.mdx +++ b/docs/content/developers/evm/intent-gateway/cancelling-orders.mdx @@ -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, @@ -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") @@ -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, diff --git a/docs/content/developers/evm/intent-gateway/placing-orders.mdx b/docs/content/developers/evm/intent-gateway/placing-orders.mdx index c1e2887f78..2c268200ef 100644 --- a/docs/content/developers/evm/intent-gateway/placing-orders.mdx +++ b/docs/content/developers/evm/intent-gateway/placing-orders.mdx @@ -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. + ```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" @@ -154,9 +156,8 @@ 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) }) @@ -164,7 +165,7 @@ const walletClient = createWalletClient({ account, transport: http(RPC_URL) }) ```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" @@ -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) }) diff --git a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts index f6764a4577..5ce5e60713 100644 --- a/sdk/packages/sdk/src/chains/intentsCoprocessor.ts +++ b/sdk/packages/sdk/src/chains/intentsCoprocessor.ts @@ -476,6 +476,8 @@ 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 @@ -483,6 +485,7 @@ export class IntentsCoprocessor { 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)) { @@ -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}` : ""}`, } ) } diff --git a/sdk/packages/sdk/src/protocols/intents/IntentGateway.ts b/sdk/packages/sdk/src/protocols/intents/IntentGateway.ts index 5c96354414..8e0c67818b 100644 --- a/sdk/packages/sdk/src/protocols/intents/IntentGateway.ts +++ b/sdk/packages/sdk/src/protocols/intents/IntentGateway.ts @@ -144,7 +144,9 @@ export class IntentGateway { * * @param source - Source chain client. * @param dest - Destination chain client. - * @param intentsCoprocessor - Optional coprocessor, read for bids beside the HyperFX orderbook. + * @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, @@ -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, read for bids beside the HyperFX orderbook + * @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 */ diff --git a/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts b/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts index 442e1d13a3..5e1cd3986e 100644 --- a/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts +++ b/sdk/packages/sdk/src/protocols/intents/OrderExecutor.ts @@ -161,10 +161,11 @@ export class OrderExecutor { * * A solver posts its bid to both when it holds BRIDGE for the Hyperbridge deposit and to the * orderbook alone when it does not, so neither source is the whole picture. The two are read - * side by side and merged by solver and bid identifier (`keccak256` of the op's calldata, which - * is how Hyperbridge files a solver's bids), the Hyperbridge copy winning a tie since it - * carries the deposit. One source failing is not the read failing: - * its bids are left out this round and the other's are returned; only both failing throws. + * 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. @@ -173,13 +174,15 @@ export class OrderExecutor { 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 { - const { commitment, solver, solverLockStartTime } = params + const { commitment, solver, solverLockStartTime, userOpHashKey } = params const reads: Promise[] = [] const intentsCoprocessor = this.ctx.intentsCoprocessor if (intentsCoprocessor) reads.push(intentsCoprocessor.getBidsForOrder(commitment)) - reads.push(this.orderbook().bids(commitment).then((bids) => bids.map(fillerBidOf))) + 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") @@ -194,7 +197,7 @@ export class OrderExecutor { for (const result of settled) { if (result.status !== "fulfilled") continue for (const bid of result.value) { - const key = `${bid.userOp.sender.toLowerCase()}:${bid.bid.toLowerCase()}` + 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) @@ -466,7 +469,7 @@ export class OrderExecutor { const auctionSeenBids = new Set() 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)), ) @@ -496,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 @@ -579,10 +582,16 @@ function servesOpenLeg(bid: Bid, remainingAssets: TokenInfo[]): boolean { } /** - * An orderbook bid in the shape Hyperbridge's bids take, so the two merge. The orderbook holds no - * deposit and does not know the solver's Substrate account, so `filler` is the solver's address. + * 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 fillerBidOf(bid: OrderbookBid): FillerBid { - const userOp = decodeUserOpScale(bid.userOp) - return { filler: bid.solver, bid: CryptoUtils.bidId(userOp.callData), userOp, deposit: 0n, venue: "orderbook" } +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 [] + } } diff --git a/sdk/packages/sdk/src/tests/bidSelection.test.ts b/sdk/packages/sdk/src/tests/bidSelection.test.ts index 29564fe873..9dca4dc3f0 100644 --- a/sdk/packages/sdk/src/tests/bidSelection.test.ts +++ b/sdk/packages/sdk/src/tests/bidSelection.test.ts @@ -503,16 +503,23 @@ describe("Order execution bid-selection integration", () => { async function readBids(ctx: unknown, orderbook: () => HyperFxOrderbook): Promise { const executor = new OrderExecutor(ctx as never, new BidManager(ctx as never, {} as never), orderbook) const { fetchBids } = executor as unknown as { - fetchBids(params: { commitment: HexString; solverLockStartTime: number }): Promise + fetchBids(params: { + commitment: HexString + solverLockStartTime: number + userOpHashKey: (userOp: PackedUserOperation) => string + }): Promise } - return fetchBids.call(executor, { commitment: COMMITMENT, solverLockStartTime: Date.now() }) + return fetchBids.call(executor, { commitment: COMMITMENT, solverLockStartTime: Date.now(), userOpHashKey }) } + /** Stands in for the EntryPoint's userOpHash: the op itself, whatever identifier it was filed under. */ + const userOpHashKey = (userOp: PackedUserOperation) => `${userOp.sender}:${userOp.callData}:${userOp.nonce}` + function hyperbridgeBid(userOp: PackedUserOperation, deposit = 10n): FillerBid { return { filler: "5Hyperbridge", bid: CryptoUtils.bidId(userOp.callData), userOp, deposit } } - it("reads both sources and merges a bid posted to both under its identifier, keeping the deposit", async () => { + it("reads both sources and merges a bid posted to both by its op, keeping the deposit", async () => { const shared = { ...makeUserOp(SOLVER_ONE), callData: "0xaaaa" as HexString } const orderbookOnly = { ...makeUserOp(SOLVER_TWO), callData: "0xbbbb" as HexString } const ctx = makeContext({ @@ -535,6 +542,36 @@ describe("Order execution bid-selection integration", () => { expect(bids[1].userOp).toEqual(orderbookOnly) }) + it("merges a bid a solver filed on Hyperbridge under another identifier, since the op is the same", async () => { + const op = makeUserOp(SOLVER_ONE) + const ctx = makeContext({ + getBidsForOrder: async () => [{ ...hyperbridgeBid(op), bid: `0x${"77".repeat(32)}` as HexString }], + readContract: gatewayReads({}), + getBlockNumber: async () => 0n, + }) + + const bids = await readBids(ctx, orderbookWith([orderbookBid(op)])) + + expect(bids).toHaveLength(1) + expect(bids[0]).toMatchObject({ bid: `0x${"77".repeat(32)}`, deposit: 10n }) + }) + + it("leaves out an orderbook bid whose op does not decode, and keeps the rest", async () => { + const op = makeUserOp(SOLVER_ONE) + const ctx = makeContext({ readContract: gatewayReads({}), getBlockNumber: async () => 0n }) + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined) + try { + const bids = await readBids( + ctx, + orderbookWith([{ ...orderbookBid(op), userOp: "0x00" as HexString }, orderbookBid(op)]), + ) + expect(bids).toHaveLength(1) + expect(bids[0].userOp).toEqual(op) + } finally { + warn.mockRestore() + } + }) + it("reads the orderbook alone when no coprocessor is configured", async () => { const op = makeUserOp(SOLVER_ONE) const ctx = makeContext({ readContract: gatewayReads({}), getBlockNumber: async () => 0n }) diff --git a/sdk/packages/sdk/src/tests/intentsCoprocessorPending.test.ts b/sdk/packages/sdk/src/tests/intentsCoprocessorPending.test.ts index dbf8eba509..c819c96a1f 100644 --- a/sdk/packages/sdk/src/tests/intentsCoprocessorPending.test.ts +++ b/sdk/packages/sdk/src/tests/intentsCoprocessorPending.test.ts @@ -289,7 +289,7 @@ describe("in-flight extrinsic handling", () => { expect(result.success).toBe(false) expect(result.pending).toBeUndefined() - expect(result.error).toBe("Transaction failed after 3 attempts") + expect(result.error).toMatch(/^Transaction failed after 3 attempts/) expect(calls.count).toBe(3) }) diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-09-22-testnet-swap-e2e.md b/sdk/packages/simplex/docs/ai/changelog/2026-09-22-testnet-swap-e2e.md index f5028a4472..f21e8ce3c4 100644 --- a/sdk/packages/simplex/docs/ai/changelog/2026-09-22-testnet-swap-e2e.md +++ b/sdk/packages/simplex/docs/ai/changelog/2026-09-22-testnet-swap-e2e.md @@ -25,7 +25,8 @@ not exceed the order's size. A tally that cannot be read is logged and does not | `multi-leg-levels` | both pairs, solver 1's levels only | | `multi-leg-same-input` | two 5.5 USDC → cNGN legs against a level that takes 10 | | `onchain-limit` | 0.5 USDC for cNGN on BSC Chapel, then the checks of the on-chain limit | -| `orderbook-only-bid` | the same swap, with solver 1 running on a Hyperbridge account holding no BRIDGE | +| `orderbook-only-bid` | the same swap, with solver 1 running with no Hyperbridge key | +| `orderbook-only-swapper` | the same swap, with the swapper's SDK given no `IntentsCoprocessor` | | `onchain-limit-refusal` | the same swap, with the best bid's limit order left one unit short of what it pays | `multi-leg` and `multi-leg-levels` put two pairs in one order, which #1311 forbids (`mixedPairs`). @@ -59,16 +60,20 @@ exactly what it approved. The runner then reads from the chain that: Both scenarios fail whatever their outcome when a solver's account on the fill chain is not delegated to an implementation with `spent`, and the reason names what the account delegates to. -`orderbook-only-bid` (`check: "orderbook"`, `noBridge: 0`) covers a solver bidding without BRIDGE. -Before its book is posted, the runner restarts solver 1 on a Hyperbridge account generated for the -run and never funded (`substratePrivateKey` alone changes; the data directory and EVM key stay), -so simplex finds no BRIDGE for the bid deposit and posts its bid to the orderbook alone, while -solvers 2 and 3 bid through Hyperbridge as usual. The swap worker points the SDK at the orderbook -under test (`withOrderbook`), and whenever bids arrive reads `intents_getBidsForOrder` and the -orderbook's `bids(commitment)` to record which solvers each venue serves (`result.venues`, also a -column of the summary). The scenario passes when the order is filled, solver 1's address is among -the orderbook's bids and not among Hyperbridge's, and a fill is solver 1's. Solver 1 is restarted on -its own key before the next scenario, whatever the outcome. +`orderbook-only-bid` (`check: "orderbook"`, `noKey: 0`) covers a solver bidding with no Hyperbridge +account. Before its book is posted, the runner restarts solver 1 on a config with no +`substratePrivateKey` or `hyperbridgeWsUrl` (the data directory and EVM key stay), so simplex posts +its bid to the orderbook alone, while solvers 2 and 3 bid through Hyperbridge as usual. The swap +worker points the SDK at the orderbook under test (`withOrderbook`), and whenever bids arrive reads +`intents_getBidsForOrder` and the orderbook's `bids(commitment)` to record which solvers each venue +serves (`result.venues`, also a column of the summary). The scenario passes when the order is +filled, solver 1's address is among the orderbook's bids and not among Hyperbridge's, and a fill is +solver 1's. Solver 1 is restarted on its own key before the next scenario, whatever the outcome. + +`orderbook-only-swapper` (`check: "orderbook-read"`, `noCoprocessor: true`) covers the other side: +the swap worker creates the SDK's `IntentGateway` with no `IntentsCoprocessor`, so it reads bids from +the orderbook alone. It passes when the order is filled and the orderbook served at least one bid. +The worker still connects its own coprocessor for the venue reads. Pass scenario names as arguments, or through `E2E_SCENARIOS` (comma-separated, spaces allowed), to run a subset. Without names, every scenario except the `mixedPairs` ones runs. @@ -79,7 +84,7 @@ The orderbook refuses a limit order paying out under 10 USDC or 15000 cNGN Swap sizes are the scenarios' own business, and most take a fraction of one limit order: 0.5 USDC, 0.4 USDC, 2000 cNGN. Only the ladder scenarios are larger, because reaching a second level means -swapping more than the first level's 10 USDC. A default run spends 24.9 USDC on BSC Chapel and +swapping more than the first level's 10 USDC. A default run spends 25.4 USDC on BSC Chapel and 20000 cNGN on Polygon Amoy. ## Wallets diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-10-21-bids-on-the-orderbook.md b/sdk/packages/simplex/docs/ai/changelog/2026-10-21-bids-on-the-orderbook.md index e5c5241c9d..6ba858eff7 100644 --- a/sdk/packages/simplex/docs/ai/changelog/2026-10-21-bids-on-the-orderbook.md +++ b/sdk/packages/simplex/docs/ai/changelog/2026-10-21-bids-on-the-orderbook.md @@ -14,17 +14,26 @@ the raw `IntentsCoprocessor`. The strategy still builds and signs the fill UserO only from a solver holding an active limit order on the leg's book filling on the order's destination (`NO_LIMIT_ORDER`), serves it for `serverInfo.bidTtlSecs` (600 s on testnet), and has no way to withdraw it; the op's own `validUntil` bounds execution as before. -- **Hyperbridge, when the account holds BRIDGE for it.** `BridgeBalanceGate` reads the account's - free balance over the coprocessor's query API and lets a bid through while it covers one bid's - cost: the pallet's storage deposit (the on-chain `StorageDepositFee` when set, else the runtime - constant), the existential deposit, and the extrinsic fee as `paymentInfo` estimates it. The - reading stands for `BRIDGE_BALANCE_TTL_MS` (30 s), is lowered by one bid's cost after each - placement that landed or pooled, and is zeroed when Hyperbridge answers `InsufficientBalance` - or refuses the fee. A read that fails lets the bid through; Hyperbridge's own refusal costs - nothing. Without BRIDGE the bid goes to the orderbook alone, logged once at `warn` until the - balance is back. +- **Hyperbridge, when the account holds BRIDGE for it.** `BridgeBalanceGate` (one per filler) + reads the account's free balance over the coprocessor's query API and lets a bid through while + it covers one bid's cost: the pallet's storage deposit (the on-chain `StorageDepositFee` when + set, else the runtime constant), the existential deposit, and the extrinsic fee as `paymentInfo` + estimates it. The reading stands for `BRIDGE_BALANCE_TTL_MS` (30 s), is lowered by one bid's + cost after each placement that landed or pooled, and is zeroed when Hyperbridge answers + `Inability to pay some fees` or `InsufficientBalance` (not Substrate's generic 1010, which a + stale nonce carries too). A read that fails lets the bid through and is not retried before the + TTL, so a dead endpoint costs one wait per TTL. The gate logs once at `warn` when bids start + going to the orderbook alone and once at `info` when the balance is back. Only the pool's fee + refusal is free: `place_bid` is not `Pays::No`, so an included extrinsic that fails with + `InsufficientBalance` on the deposit still pays its fee, which is what the gate is there to + avoid. +- **Or no Hyperbridge at all.** `simplex.substratePrivateKey` and `simplex.hyperbridgeWsUrl` are + optional (the URL is required only with a key). Without a key the solver still delegates its + SolverAccount and funds the EntryPoint (`setupSolverSelection` no longer waits on a Hyperbridge + connection) and bids on the orderbook alone. `simplex init` lets the key be skipped and then + writes neither field; the UI wizard does the same. -The two venues are posted side by side. `FillResult.success`, `txHash`, `pending` and `error` +The orderbook post goes out first and does not wait on the balance read. `FillResult.success`, `txHash`, `pending` and `error` remain Hyperbridge's answer; `FillResult.orderbook` is the orderbook's (`SubmitBidResult`: `accepted`, `unchanged`, `rejected` with a `RejectionCode`, or `failed`, including `REQUEST_FAILED` for a request that got no answer). A bid is *placed* when Hyperbridge took or @@ -43,6 +52,14 @@ an orderbook bid. `success` and `pending` keep their Hyperbridge meaning, so: - The retraction sweep, `retractBidsForLimitOrder` and the fill-time retractions touch only rows with a Hyperbridge deposit (`success || pending`). An orderbook-only row is left to expire. +- **What retraction now guarantees.** The orderbook cannot withdraw a bid, so when a limit order + is cancelled, re-priced or closed, retraction pulls the Hyperbridge copy only; the orderbook + keeps serving the op until `bidTtlSecs`, and the op's own `validUntil` bounds it as before. The + limit order's on-chain budget (`debitOrder`) is the only cap on what such a bid can still fill, + which is the same cap a retracted Hyperbridge bid already executing was held to. +- `BidStats.successful` and `failed`, and `BidStore.failed()`, count a bid the orderbook alone + holds as placed, not failed, in both stores: the dashboard's **Failed** figure means no venue + took the bid. - Fill attribution (`recordExecutedFill`) counts an orderbook-only row as a live candidate, inside the same window a retracted bid gets (`bidValiditySeconds` + discovery pad + permit skew, back from the fill's block time), since nothing retracts it and an expired one cannot have executed. @@ -59,17 +76,20 @@ and `TOO_MANY_BIDS`. The schema fixture is refreshed from `polytope-labs/hyperfx `HyperFxOrderbook.bids(commitment, { leg?, solver? })` returns `OrderbookBid[]` (solver, commitment, leg, book, side, chains, tokens, `price`, `amountIn`, `amountOut` at 1e18, `receivedAt`, `expiresAt`, and the SCALE-encoded `userOp`). `OrderExecutor.fetchBids` reads the -coprocessor (when configured) and the gateway's orderbook together and merges by -`(sender, bidId)`, where the bid id is `keccak256(callData)` as Hyperbridge files it; the -Hyperbridge copy wins a tie since it carries the deposit. One source failing leaves the other's -bids; both failing throws. `FillerBid.venue` is `"orderbook"` for a bid read there (its `filler` -is the solver's address, `deposit` is 0). `IntentGateway.executeOrder` no longer requires a -coprocessor, and `withOrderbook` now also redirects where bids are read from. +coprocessor (when configured) and the gateway's orderbook together and merges by the op's +userOpHash, so a bid a solver filed on Hyperbridge under some other identifier still merges with +its orderbook copy; the Hyperbridge copy wins a tie since it carries the deposit. One source +failing leaves the other's bids; both failing throws; an orderbook bid whose op does not decode is +skipped on its own. `FillerBid.venue` is `"orderbook"` for a bid read there (its `filler` is the +solver's address, `deposit` is 0). The coprocessor is optional by contract: `IntentGateway.create` +without one reads bids from the orderbook alone, which the docs' setup examples now show; +`withOrderbook` also redirects where bids are read from. `IntentsCoprocessor`'s "Transaction +failed after N attempts" now carries the last attempt's error. ## End to end -The testnet swaps run gains `orderbook-only-bid`: the `same-chain` swap with solver 1 restarted on -an unfunded Hyperbridge account, so its bid can only reach the orderbook. The swap worker reads -the SDK's bids from the orderbook under test and records which solvers each venue served; the -scenario passes only when solver 1's bid was on the orderbook, not on Hyperbridge, and filled the -order. See [the e2e note](2026-09-22-testnet-swap-e2e.md). +The testnet swaps run gains `orderbook-only-bid`, the `same-chain` swap with solver 1 restarted +with no Hyperbridge key, so its bid can only reach the orderbook, and `orderbook-only-swapper`, +the same swap with the SDK given no coprocessor, so it reads bids from the orderbook alone. The +swap worker reads the SDK's bids from the orderbook under test and records which solvers each +venue served. See [the e2e note](2026-09-22-testnet-swap-e2e.md). diff --git a/sdk/packages/simplex/e2e/env.mjs b/sdk/packages/simplex/e2e/env.mjs index 3f506f15b7..421107ba35 100644 --- a/sdk/packages/simplex/e2e/env.mjs +++ b/sdk/packages/simplex/e2e/env.mjs @@ -107,9 +107,11 @@ export const EXTRA_LEVELS = { * Each scenario runs against a freshly posted book: the standing orders, plus the extra * solver 1 levels it names. `minFills` is the fewest fill transactions it must take. A scenario * with `check: "limit"` or `check: "refusal"` must also pass the checks of the on-chain limit in - * e2e/budget.mjs. One with `noBridge` runs that solver (by index) on a Hyperbridge account holding - * no BRIDGE, so its bids can only reach the orderbook; `check: "orderbook"` then requires the - * order to be filled by that solver from a bid the orderbook served and Hyperbridge never held. + * e2e/budget.mjs. One with `noKey` runs that solver (by index) with no Hyperbridge key at all, so + * its bids can only reach the orderbook; `check: "orderbook"` then requires the order to be filled + * by that solver from a bid the orderbook served and Hyperbridge never held. One with + * `noCoprocessor` has the swapper's SDK run without an `IntentsCoprocessor`, reading bids from the + * orderbook alone; `check: "orderbook-read"` requires a fill from bids the orderbook served. * * `mixedPairs` scenarios put two pairs in one order, which #1311 forbids. They pass only against * a gateway without that rule, so they run only when named. @@ -197,12 +199,23 @@ export const SCENARIOS = { legs: [{ tokenIn: "USDC", amountIn: "0.5", tokenOut: "cNGN", minOut: "778.5" }], minFills: 1, }, - // The `same-chain` swap with solver 1, the best priced, holding no BRIDGE: its bid cannot reach - // Hyperbridge and goes to the orderbook alone, where the SDK reads it beside the others' bids - // and executes it first. The other two solvers bid through Hyperbridge as usual. + // The `same-chain` swap with solver 1, the best priced, running with no Hyperbridge key: its + // bid goes to the orderbook alone, where the SDK reads it beside the others' bids and executes + // it first. The other two solvers bid through Hyperbridge as usual. "orderbook-only-bid": { check: "orderbook", - noBridge: 0, + noKey: 0, + user: 0, + source: "EVM-97", + dest: "EVM-97", + legs: [{ tokenIn: "USDC", amountIn: "0.5", tokenOut: "cNGN", minOut: "778.5" }], + minFills: 1, + }, + // The `same-chain` swap with the swapper's SDK given no IntentsCoprocessor: it reads the bids + // from the orderbook alone and executes one of them. + "orderbook-only-swapper": { + check: "orderbook-read", + noCoprocessor: true, user: 0, source: "EVM-97", dest: "EVM-97", diff --git a/sdk/packages/simplex/e2e/run.mjs b/sdk/packages/simplex/e2e/run.mjs index c7259bf835..65f14ffbf4 100644 --- a/sdk/packages/simplex/e2e/run.mjs +++ b/sdk/packages/simplex/e2e/run.mjs @@ -13,8 +13,7 @@ import os from "node:os" import path from "node:path" import { fileURLToPath } from "node:url" import { ApiPromise, Keyring, WsProvider } from "@polkadot/api" -import { u8aToHex } from "@polkadot/util" -import { cryptoWaitReady, keccakAsU8a, randomAsU8a } from "@polkadot/util-crypto" +import { cryptoWaitReady, keccakAsU8a } from "@polkadot/util-crypto" import { createPublicClient, createWalletClient, erc20Abi, formatEther, formatUnits, http, parseUnits } from "viem" import { privateKeyToAccount } from "viem/accounts" import { checkBudgets, keep } from "./budget.mjs" @@ -60,26 +59,23 @@ const solverKeys = [ { key: env.solver3Key, substrate: env.solver3Substrate }, ] -/** - * A Hyperbridge account nobody has funded, for the solver a `noBridge` scenario runs without - * BRIDGE: it can connect and read, but not pay for a bid, so its bids reach the orderbook alone. - * Fresh each run, and redacted like a real key. - */ -const UNFUNDED_SUBSTRATE_KEY = u8aToHex(randomAsU8a(32)) -SECRETS.unshift(UNFUNDED_SUBSTRATE_KEY) +/** Marks a solver run with no Hyperbridge key at all, so its bids reach the orderbook alone. */ +const NO_KEY = Symbol("no Hyperbridge key") -/** @param substrate - The Hyperbridge key the solver bids with; its own unless a scenario withholds it. */ +/** @param substrate - The Hyperbridge key the solver bids with: its own, or {@link NO_KEY} for none. */ function solverConfig(index, substrate = solverKeys[index].substrate) { const q = JSON.stringify + const hyperbridge = + substrate === NO_KEY + ? "# No Hyperbridge key: bids go to the orderbook alone.\n" + : `substratePrivateKey = ${q(substrate)}\nhyperbridgeWsUrl = ${q(env.hyperbridge)}\n` return `[simplex] # The scenarios exercise partial-fill mechanics with testnet amounts below the production floor. minPartialFillUsd = 0 maxConcurrentOrders = 5 logging = "info" bidValiditySeconds = 600 -substratePrivateKey = ${q(substrate)} -hyperbridgeWsUrl = ${q(env.hyperbridge)} - +${hyperbridge} [simplex.signer] type = "privateKey" key = ${q(solverKeys[index].key)} @@ -140,7 +136,7 @@ function startSolver(index, substrate) { ) child.on("exit", (code, signal) => log(`${solver.name} exited (${code ?? signal})`)) processes[index] = child - log(`${solver.name} started, pid ${child.pid}${substrate ? " on an unfunded Hyperbridge account" : ""}`) + log(`${solver.name} started, pid ${child.pid}${substrate === NO_KEY ? " with no Hyperbridge key" : ""}`) } function startSolvers() { @@ -158,8 +154,9 @@ async function stopSolvers() { } /** - * Stops one solver and starts it again on `substrate`, its own key when none is given. The - * data directory stays, so its limit orders are still its to withdraw when the book is reposted. + * Stops one solver and starts it again on `substrate`: its own key when none is given, or none + * at all ({@link NO_KEY}). The data directory stays, so its limit orders are still its to + * withdraw when the book is reposted. */ async function restartSolver(index, substrate) { await stopSolver(processes[index]) @@ -183,17 +180,21 @@ async function waitReady() { } /** - * The checks of a `check: "orderbook"` scenario: the order was filled by the solver running - * without BRIDGE, from a bid the orderbook served and Hyperbridge never held. A solver that - * could still reach Hyperbridge, or whose bid the orderbook refused, fails it by name. - * Returns the reason it fails, or undefined. + * The checks of the orderbook scenarios. `check: "orderbook"`: the order was filled by the + * solver running with no Hyperbridge key, from a bid the orderbook served and Hyperbridge never + * held; a solver that still reached Hyperbridge, or whose bid the orderbook refused, fails it by + * name. `check: "orderbook-read"`: the swapper's SDK, given no coprocessor, filled from bids the + * orderbook served. Returns the reason it fails, or undefined. */ function orderbookChecks(scenario, result) { - const solver = SOLVERS[scenario.noBridge] - const address = SOLVER_ACCOUNTS[scenario.noBridge].address.toLowerCase() const venues = result.venues - if (!venues) return `${solver.name}'s bid venues were never read` - if (venues.hyperbridge.includes(address)) return `${solver.name} holds no BRIDGE, yet Hyperbridge served its bid` + if (!venues) return "the bid venues were never read" + if (scenario.check === "orderbook-read") { + return venues.orderbook.length > 0 ? undefined : "the orderbook served no bids, yet the order was filled" + } + const solver = SOLVERS[scenario.noKey] + const address = SOLVER_ACCOUNTS[scenario.noKey].address.toLowerCase() + if (venues.hyperbridge.includes(address)) return `${solver.name} has no Hyperbridge key, yet Hyperbridge served its bid` if (!venues.orderbook.includes(address)) { return `the orderbook served no bid from ${solver.name}; it served ${venues.orderbook.length} from ${venues.orderbook.join(", ") || "nobody"}` } @@ -500,16 +501,16 @@ async function main() { log(`== ${name}`) let result try { - // A solver run without BRIDGE for this scenario alone; its own key is back before the next. - if (scenario.noBridge !== undefined) await restartSolver(scenario.noBridge, UNFUNDED_SUBSTRATE_KEY) + // A solver run with no Hyperbridge key for this scenario alone; its own key is back before the next. + if (scenario.noKey !== undefined) await restartSolver(scenario.noKey, NO_KEY) await postBook(scenario.levels) result = await runScenario(name) } catch (error) { result = { scenario: name, outcome: "ERROR", error: String(error?.message ?? error), fills: [] } } finally { - if (scenario.noBridge !== undefined) { - await restartSolver(scenario.noBridge).catch((error) => - log(`${SOLVERS[scenario.noBridge].name} could not be restarted on its own key: ${error?.message ?? error}`), + if (scenario.noKey !== undefined) { + await restartSolver(scenario.noKey).catch((error) => + log(`${SOLVERS[scenario.noKey].name} could not be restarted on its own key: ${error?.message ?? error}`), ) } } @@ -517,7 +518,7 @@ async function main() { result.passed = result.outcome === "FILLED" && result.fills.length >= scenario.minFills if (result.outcome === "FILLED" && !result.passed) result.reason = `expected ${scenario.minFills}+ fills` if (result.error) result.reason = result.error.slice(0, 200) - if (scenario.check === "orderbook" && result.passed) { + if ((scenario.check === "orderbook" || scenario.check === "orderbook-read") && result.passed) { const failure = orderbookChecks(scenario, result) if (failure) { log(`${name}: ${failure}`) diff --git a/sdk/packages/simplex/e2e/swap.mjs b/sdk/packages/simplex/e2e/swap.mjs index f7ccce2298..d13a92cbf2 100644 --- a/sdk/packages/simplex/e2e/swap.mjs +++ b/sdk/packages/simplex/e2e/swap.mjs @@ -64,11 +64,13 @@ async function main() { const sourceChain = EvmChain.fromParams({ chainId: src.id, host: HOST, rpcUrl: src.rpc }) const destChain = s.source === s.dest ? sourceChain : EvmChain.fromParams({ chainId: dst.id, host: HOST, rpcUrl: dst.rpc }) + // Connected for the venue reads below even when the SDK under test is given no coprocessor. const coprocessor = await IntentsCoprocessor.connect(env.hyperbridge) // The SDK reads bids from Hyperbridge and from the orderbook the solvers post to; by default - // it would read the network's public deployment, which need not be the one under test. + // it would read the network's public deployment, which need not be the one under test. A + // `noCoprocessor` scenario leaves the SDK the orderbook alone to read from. const orderbook = new HyperFxOrderbook(env.orderbook) - const gateway = (await IntentGateway.create(sourceChain, destChain, coprocessor)).withOrderbook(orderbook) + const gateway = (await IntentGateway.create(sourceChain, destChain, s.noCoprocessor ? undefined : coprocessor)).withOrderbook(orderbook) /** * Which solvers' bids each venue serves on the order, lower-cased senders. A solver with diff --git a/sdk/packages/simplex/src/bin/simplex.ts b/sdk/packages/simplex/src/bin/simplex.ts index d6cd0bef04..cf2d73da1d 100644 --- a/sdk/packages/simplex/src/bin/simplex.ts +++ b/sdk/packages/simplex/src/bin/simplex.ts @@ -173,9 +173,12 @@ async function operatorContextFrom( writeConfigFile?: (path: string, content: string) => void, ): Promise { const runtime = simplex.internals - const substrateAddress = await deriveSubstrateKeyPair(runtime.config.simplex.substratePrivateKey) - .then((pair) => pair.address) - .catch(() => undefined) + const substrateKey = runtime.config.simplex.substratePrivateKey + const substrateAddress = substrateKey + ? await deriveSubstrateKeyPair(substrateKey) + .then((pair) => pair.address) + .catch(() => undefined) + : undefined return { addresses: { evm: runtime.fillerAddress, substrate: substrateAddress }, filler: runtime.intentFiller, diff --git a/sdk/packages/simplex/src/cli/init/emit-toml.ts b/sdk/packages/simplex/src/cli/init/emit-toml.ts index 5565b034ab..c90a9b8abb 100644 --- a/sdk/packages/simplex/src/cli/init/emit-toml.ts +++ b/sdk/packages/simplex/src/cli/init/emit-toml.ts @@ -52,11 +52,16 @@ export function emitFillerToml(config: FillerConfigFile, options: EmitOptions = push("# Watch-only mode: monitor orders without filling. Boolean (global) or per-chain map.") push(kv("watchOnly", config.simplex.watchOnly)) } - push("# Substrate account (hex seed or mnemonic) that signs bid extrinsics on Hyperbridge.") - push("# It must hold BRIDGE tokens for fees; fees are claimed back automatically after fills.") - push(kv("substratePrivateKey", config.simplex.substratePrivateKey)) - push("# Hyperbridge WebSocket endpoint used to submit solver bids.") - push(kv("hyperbridgeWsUrl", config.simplex.hyperbridgeWsUrl)) + if (config.simplex.substratePrivateKey !== undefined) { + push("# Substrate account (hex seed or mnemonic) that signs bid extrinsics on Hyperbridge.") + push("# It must hold BRIDGE tokens for deposits and fees; fees are claimed back after fills.") + push("# Without it, bids go to the HyperFX orderbook alone.") + push(kv("substratePrivateKey", config.simplex.substratePrivateKey)) + } + if (config.simplex.hyperbridgeWsUrl !== undefined) { + push("# Hyperbridge WebSocket endpoint used to submit solver bids.") + push(kv("hyperbridgeWsUrl", config.simplex.hyperbridgeWsUrl)) + } if (config.simplex.indexerUrl) push(kv("indexerUrl", config.simplex.indexerUrl)) if (config.simplex.blockScanIntervalSeconds !== undefined) { push("# Seconds between block scans per chain. Default 3, minimum 0.1.") diff --git a/sdk/packages/simplex/src/cli/init/help-text.ts b/sdk/packages/simplex/src/cli/init/help-text.ts index 82f5f5cf46..15596c0490 100644 --- a/sdk/packages/simplex/src/cli/init/help-text.ts +++ b/sdk/packages/simplex/src/cli/init/help-text.ts @@ -10,7 +10,7 @@ export const WHY = { secretPhrase: "A secret phrase is a BIP-39 mnemonic of 12 to 24 English words. The filler signs with the wallet at m/44'/60'/0'/0/0 unless you pick another account index. The phrase is written to the config file in plain text, protected only by the file's permissions, the same as a private key.", substrateKey: - "Solver-selection orders are won by submitting signed bids to Hyperbridge. This Substrate account signs those bid extrinsics and must hold BRIDGE tokens for fees — the fees are claimed back automatically after fills.", + "Bids are posted to the HyperFX orderbook and, with this Substrate account, to Hyperbridge as well. The account signs the bid extrinsics and must hold BRIDGE tokens for their deposits and fees, claimed back after fills. Leave it empty to bid on the orderbook alone.", hyperbridgeWs: "WebSocket endpoint of the Hyperbridge chain, used to submit and track solver bids.", confirmations: "Blocks to wait before filling a cross-chain order, scaled by order value — protects you from reorgs unwinding the deposit after you've paid out.", diff --git a/sdk/packages/simplex/src/cli/init/prompt-utils.ts b/sdk/packages/simplex/src/cli/init/prompt-utils.ts index b6aec0a7af..ecc1d48ac8 100644 --- a/sdk/packages/simplex/src/cli/init/prompt-utils.ts +++ b/sdk/packages/simplex/src/cli/init/prompt-utils.ts @@ -89,11 +89,15 @@ export async function askUrl( }) } -/** Masked input; when a previous value exists, pressing Enter keeps it. */ +/** + * Masked input; when a previous value exists, pressing Enter keeps it. With `optional`, an + * empty submission and no previous value returns an empty string instead of asking again. + */ export async function askSecret( message: string, previous?: string, validate?: (value: string) => string | undefined, + options: { optional?: boolean } = {}, ): Promise { for (;;) { const input = guard( @@ -101,7 +105,7 @@ export async function askSecret( message: previous ? `${message} (press Enter to keep the current value)` : message, validate: (value) => { const trimmed = (value ?? "").trim() - if (!trimmed) return previous ? undefined : "This value is required" + if (!trimmed) return previous || options.optional ? undefined : "This value is required" return validate?.(trimmed) }, }), @@ -109,6 +113,7 @@ export async function askSecret( const trimmed = (input ?? "").trim() if (trimmed) return trimmed if (previous) return previous + if (options.optional) return "" } } diff --git a/sdk/packages/simplex/src/cli/init/steps/hyperbridge.ts b/sdk/packages/simplex/src/cli/init/steps/hyperbridge.ts index cdf66b9744..c4e8323154 100644 --- a/sdk/packages/simplex/src/cli/init/steps/hyperbridge.ts +++ b/sdk/packages/simplex/src/cli/init/steps/hyperbridge.ts @@ -5,10 +5,19 @@ import type { Prefill, WizardState } from "../state" export async function stepHyperbridge(state: WizardState, prefill?: Prefill): Promise { why(WHY.substrateKey) - state.substratePrivateKey = await askSecret( - "Substrate private key (hex seed or mnemonic phrase)", + const key = await askSecret( + "Substrate private key (hex seed or mnemonic phrase; leave empty to bid on the orderbook alone)", prefill?.config.simplex.substratePrivateKey || undefined, + undefined, + { optional: true }, ) + // Without a key there is nowhere to send Hyperbridge bids, so neither field is written. + if (!key) { + state.substratePrivateKey = undefined + state.hyperbridgeWsUrl = undefined + return + } + state.substratePrivateKey = key why(WHY.hyperbridgeWs) state.hyperbridgeWsUrl = await askUrl("Hyperbridge WebSocket URL", { diff --git a/sdk/packages/simplex/src/cli/init/steps/write.ts b/sdk/packages/simplex/src/cli/init/steps/write.ts index 47cf7c738e..6b490e4449 100644 --- a/sdk/packages/simplex/src/cli/init/steps/write.ts +++ b/sdk/packages/simplex/src/cli/init/steps/write.ts @@ -119,8 +119,9 @@ export function assembleConfig(state: WizardState): FillerConfigFile { signer: state.signer, maxConcurrentOrders: state.maxConcurrentOrders, ...(state.logging !== undefined ? { logging: state.logging } : {}), - substratePrivateKey: state.substratePrivateKey ?? "", - hyperbridgeWsUrl: state.hyperbridgeWsUrl ?? "", + // Omitted rather than written empty: a solver without a Hyperbridge key bids on the orderbook alone. + substratePrivateKey: state.substratePrivateKey || undefined, + hyperbridgeWsUrl: state.hyperbridgeWsUrl || undefined, ...(state.gasFeeBump ? { gasFeeBump: state.gasFeeBump } : { gasFeeBump: undefined }), }, assets: Object.keys(assets).length > 0 ? assets : undefined, @@ -165,8 +166,8 @@ function showSummary(state: WizardState, outputPath: string): void { lines.push(`+ ${state.passthroughChains.length} unmanaged chain(s) kept from the previous config`) } lines.push(`Signer: ${state.signer?.type}`) - lines.push(`Substrate key: ${maskSecret(state.substratePrivateKey ?? "")}`) - lines.push(`Hyperbridge: ${state.hyperbridgeWsUrl}`) + lines.push(`Substrate key: ${state.substratePrivateKey ? maskSecret(state.substratePrivateKey) : "none (bids go to the orderbook alone)"}`) + lines.push(`Hyperbridge: ${state.hyperbridgeWsUrl ?? "none"}`) lines.push(`Output: ${outputPath}`) note(lines.join("\n"), "Summary") } diff --git a/sdk/packages/simplex/src/config/filler-toml.ts b/sdk/packages/simplex/src/config/filler-toml.ts index 939c275377..f601d9a6e5 100644 --- a/sdk/packages/simplex/src/config/filler-toml.ts +++ b/sdk/packages/simplex/src/config/filler-toml.ts @@ -89,8 +89,13 @@ export interface FillerTomlConfig { queue?: QueueConfig logging?: string watchOnly?: boolean | Record - substratePrivateKey: string - hyperbridgeWsUrl: string + /** + * The Hyperbridge account that signs bid extrinsics and holds BRIDGE for their deposits. + * Optional: without it the solver bids on the HyperFX orderbook alone. + */ + substratePrivateKey?: string + /** The Hyperbridge WebSocket endpoint bids go to. Required when a key is set. */ + hyperbridgeWsUrl?: string /** * Hyperbridge indexer GraphQL endpoint, used to backfill order details on * activity rows recorded before they were captured. Defaults per network @@ -308,12 +313,9 @@ export function validateConfig(config: FillerTomlConfig, cliWatchOnly = false): // (otherwise the flag's own config would still trip the checks it exempts). const allChainsWatchOnly = cliWatchOnly || config.simplex?.watchOnly === true - if (!config.simplex?.substratePrivateKey) { - throw new Error("simplex.substratePrivateKey is required") - } - - if (!config.simplex?.hyperbridgeWsUrl) { - throw new Error("simplex.hyperbridgeWsUrl is required") + // A solver without a Hyperbridge key bids on the orderbook alone; with one it needs somewhere to send bids. + if (config.simplex?.substratePrivateKey && !config.simplex.hyperbridgeWsUrl) { + throw new Error("simplex.hyperbridgeWsUrl is required when simplex.substratePrivateKey is set") } if (!config.chains || config.chains.length === 0) { diff --git a/sdk/packages/simplex/src/core/bid-venues.ts b/sdk/packages/simplex/src/core/bid-venues.ts index 785d5bc28c..fd49f16a8e 100644 --- a/sdk/packages/simplex/src/core/bid-venues.ts +++ b/sdk/packages/simplex/src/core/bid-venues.ts @@ -36,22 +36,30 @@ export const BRIDGE_BALANCE_TTL_MS = 30_000 * * `place_bid` reserves `StorageDepositFee` and the extrinsic pays its fee out of the free * balance, which must also stay above the existential deposit. A filler without that much - * cannot bid on Hyperbridge at all: the extrinsic fails with `InsufficientBalance` or is - * refused at the pool for its fee, and either way pays nothing and places nothing. So the - * balance is read before each bid and, when it falls short, the bid goes to the orderbook alone. + * cannot bid on Hyperbridge: with nothing for the fee the extrinsic is refused at the pool, + * which places nothing and costs nothing, and with the fee but not the deposit it is included, + * fails with `InsufficientBalance`, and pays the fee for nothing (`place_bid` is not `Pays::No`). + * So the balance is read before each bid and, when it falls short, the bid goes to the orderbook + * alone. * * The reading is cached for {@link BRIDGE_BALANCE_TTL_MS} and lowered by one bid's cost after * each placement that reached Hyperbridge, so a burst of bids does not overrun a balance the * next read has not caught up with. A placement Hyperbridge refuses for want of balance * zeroes the reading until the next read. The read is over the coprocessor's query API; a read * that fails leaves the last reading in place, or, with none, lets the bid through to - * Hyperbridge, whose own refusal costs nothing. + * Hyperbridge, and is not tried again before the TTL, so a dead endpoint costs one wait per + * TTL rather than one per bid. + * + * The gate is long-lived, one per filler, so it is also where a change of answer is logged: + * once at `warn` when bids start going to the orderbook alone, once at `info` when they go to + * Hyperbridge again. */ export class BridgeBalanceGate { private free?: bigint private readAt = 0 private cost?: bigint private read?: Promise + private hadBalance?: boolean constructor( private readonly hyperbridge: IntentsCoprocessor, @@ -61,14 +69,23 @@ export class BridgeBalanceGate { /** Whether the account can afford one more bid on Hyperbridge. */ async canPlaceBid(): Promise { - if (this.free === undefined || this.now() - this.readAt > BRIDGE_BALANCE_TTL_MS) { + if (this.now() - this.readAt > BRIDGE_BALANCE_TTL_MS) { this.read ??= this.refresh().finally(() => { this.read = undefined }) await this.read } - if (this.free === undefined || this.cost === undefined) return true - return this.free >= this.cost + const can = this.free === undefined || this.cost === undefined || this.free >= this.cost + if (!can && this.hadBalance !== false) { + this.logger.warn( + { free: this.free?.toString(), cost: this.cost?.toString() }, + "No BRIDGE for a bid deposit; bids go to the orderbook alone until the balance is topped up", + ) + } else if (can && this.hadBalance === false) { + this.logger.info("BRIDGE balance restored; bids go to Hyperbridge and the orderbook again") + } + this.hadBalance = can + return can } /** @@ -93,16 +110,21 @@ export class BridgeBalanceGate { } private async refresh(): Promise { + // Whatever the read says, it stands for the TTL: a dead endpoint is waited out once, not per bid. + this.readAt = this.now() try { const api = await this.hyperbridge.queryApi() const address = this.hyperbridge.getKeyPair().address - // biome-ignore lint/suspicious/noExplicitAny: polkadot-js codec const account = (await api.query.system.account(address)) as any this.free = BigInt(account.data.free.toString()) - this.readAt = this.now() if (this.cost === undefined) this.cost = await this.bidCost(api) } catch (err) { - this.logger.warn({ err }, "Could not read the BRIDGE balance; bids go to Hyperbridge as usual") + this.logger.warn( + { err }, + this.free === undefined + ? "Could not read the BRIDGE balance; bids go to Hyperbridge as usual" + : "Could not read the BRIDGE balance; keeping the last reading", + ) } } @@ -111,7 +133,6 @@ export class BridgeBalanceGate { * current value, or its constant when none has been set), the existential deposit the free * balance must keep, and the extrinsic's fee as estimated for a representative `place_bid`. */ - // biome-ignore lint/suspicious/noExplicitAny: polkadot-js api private async bidCost(api: any): Promise { const stored = BigInt((await api.query.intentsCoprocessor.storageDepositFee()).toString()) const deposit = stored > 0n ? stored : BigInt(api.consts.intentsCoprocessor.storageDepositFee.toString()) @@ -130,24 +151,27 @@ export class BridgeBalanceGate { } } -/** Hyperbridge's answer when the account cannot pay for the bid, as the SDK renders it. */ +/** + * Hyperbridge's answer when the account cannot pay for the bid, as the SDK renders it: the pool's + * fee refusal, or the pallet's `InsufficientBalance` on the deposit. Substrate's generic 1010 code + * is not matched, since a stale nonce or a bad signature carries it too. + */ function wantsBalance(result: BidSubmissionResult): boolean { return ( !result.success && !result.pending && - /InsufficientBalance|Inability to pay some fees|FundsUnavailable|1010/.test(result.error ?? "") + /Inability to pay some fees|InsufficientBalance|FundsUnavailable/.test(result.error ?? "") ) } /** - * The venues a bid goes to: Hyperbridge when the filler holds BRIDGE for the deposit, and the - * HyperFX orderbook always. The two are posted side by side, since Hyperbridge waits for its - * extrinsic to land and the orderbook answers at once. One venue's failure is not the other's: - * each answer is returned on its own, and the caller decides what the bid is worth from them. + * The venues a bid goes to: the HyperFX orderbook always, and Hyperbridge when the filler holds + * BRIDGE for the deposit. The orderbook post goes out first and does not wait on the balance + * read, which can take a dead query endpoint's whole timeout; Hyperbridge's own submission waits + * for its extrinsic to land. One venue's failure is not the other's: each answer is returned on + * its own, and the caller decides what the bid is worth from them. */ export class BidVenues implements BidPlacer { - private hadBalance?: boolean - constructor( private readonly venues: { hyperbridge?: IntentsCoprocessor @@ -160,20 +184,11 @@ export class BidVenues implements BidPlacer { async place(commitment: HexString, userOp: HexString, bid: HexString): Promise { const { hyperbridge, orderbook, bridgeBalance } = this.venues + const onOrderbookPending = orderbook ? this.postToOrderbook(commitment, userOp) : undefined const toHyperbridge = hyperbridge !== undefined && (bridgeBalance === undefined || (await bridgeBalance.canPlaceBid())) - if (hyperbridge && !toHyperbridge && this.hadBalance !== false) { - this.logger.warn( - { free: bridgeBalance?.freeBalance?.toString() }, - "No BRIDGE for a bid deposit; bids go to the orderbook alone until the balance is topped up", - ) - } else if (toHyperbridge && this.hadBalance === false) { - this.logger.info("BRIDGE balance restored; bids go to Hyperbridge and the orderbook again") - } - if (hyperbridge) this.hadBalance = toHyperbridge - const [onHyperbridge, onOrderbook] = await Promise.all([ toHyperbridge ? hyperbridge.submitBid(commitment, userOp, bid) : undefined, - orderbook ? this.postToOrderbook(commitment, userOp) : undefined, + onOrderbookPending, ]) if (onHyperbridge && bridgeBalance) { if (onHyperbridge.success || onHyperbridge.pending) bridgeBalance.spent() diff --git a/sdk/packages/simplex/src/core/filler.ts b/sdk/packages/simplex/src/core/filler.ts index 3842e75d6b..7fc7be63bc 100644 --- a/sdk/packages/simplex/src/core/filler.ts +++ b/sdk/packages/simplex/src/core/filler.ts @@ -532,7 +532,9 @@ export class IntentFiller { * look configured while silently declining every order on it. */ private async setupSolverSelection(chains: string[], { requireAll }: { requireAll: boolean }): Promise { - if (chains.length === 0 || !this.hyperbridge) return + // Not gated on a Hyperbridge connection: a solver bidding on the orderbook alone still + // fills from its SolverAccount, which has to be delegated and funded at the EntryPoint. + if (chains.length === 0) return const delegationService = this.delegation() this.logger.info({ chains }, "Setting up EIP-7702 delegation on chains with solver selection") diff --git a/sdk/packages/simplex/src/data/memory.ts b/sdk/packages/simplex/src/data/memory.ts index b592513135..922c50c829 100644 --- a/sdk/packages/simplex/src/data/memory.ts +++ b/sdk/packages/simplex/src/data/memory.ts @@ -51,6 +51,11 @@ function reclaimable(row: StoredBid): boolean { return row.success || row.pending } +/** A bid some venue took: Hyperbridge, or the orderbook. */ +function placed(row: StoredBid): boolean { + return row.success || row.onOrderbook +} + function capLimit(limit: number): number { return Math.min(Math.max(limit, 1), MAX_LIMIT) } @@ -163,7 +168,7 @@ class MemoryBidStore implements BidStore { async failed(limit = 100): Promise { return this.rows - .filter((row) => !row.success) + .filter((row) => !placed(row)) .slice(-capLimit(limit)) .reverse() .map((row) => ({ ...row })) @@ -181,8 +186,8 @@ class MemoryBidStore implements BidStore { async stats(): Promise { return { total: this.rows.length, - successful: this.rows.filter((row) => row.success).length, - failed: this.rows.filter((row) => !row.success).length, + successful: this.rows.filter(placed).length, + failed: this.rows.filter((row) => !placed(row)).length, retracted: this.rows.filter((row) => row.retracted).length, pendingRetraction: this.rows.filter((row) => reclaimable(row) && !row.retracted).length, } diff --git a/sdk/packages/simplex/src/data/sqlite/bids.ts b/sdk/packages/simplex/src/data/sqlite/bids.ts index c9dda5c83d..0ca27b696a 100644 --- a/sdk/packages/simplex/src/data/sqlite/bids.ts +++ b/sdk/packages/simplex/src/data/sqlite/bids.ts @@ -255,8 +255,8 @@ export class SqliteBidStore implements BidStore { .prepare(` SELECT COUNT(*) as total, - SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful, - SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as failed, + SUM(CASE WHEN success = 1 OR on_orderbook = 1 THEN 1 ELSE 0 END) as successful, + SUM(CASE WHEN success = 0 AND on_orderbook = 0 THEN 1 ELSE 0 END) as failed, SUM(CASE WHEN retracted = 1 THEN 1 ELSE 0 END) as retracted, SUM(CASE WHEN (success = 1 OR pending = 1) AND retracted = 0 THEN 1 ELSE 0 END) as pendingRetraction FROM bids @@ -274,7 +274,7 @@ export class SqliteBidStore implements BidStore { async failed(limit = 100): Promise { const rows = this.db - .prepare(`SELECT ${BID_COLUMNS} FROM bids WHERE success = 0 ORDER BY created_at DESC LIMIT ?`) + .prepare(`SELECT ${BID_COLUMNS} FROM bids WHERE success = 0 AND on_orderbook = 0 ORDER BY created_at DESC LIMIT ?`) .all(limit) // biome-ignore lint/suspicious/noExplicitAny: raw sqlite row return (rows as any[]).map((row) => this.toStoredBid(row)) diff --git a/sdk/packages/simplex/src/data/types.ts b/sdk/packages/simplex/src/data/types.ts index 197300a241..b70c9884e9 100644 --- a/sdk/packages/simplex/src/data/types.ts +++ b/sdk/packages/simplex/src/data/types.ts @@ -116,7 +116,9 @@ export interface BidInsert { export interface BidStats { total: number + /** Bids a venue took: Hyperbridge, or the orderbook alone. */ successful: number + /** Bids no venue took. */ failed: number retracted: number /** Deposits still locked: successful or pending, and not yet retracted. */ @@ -178,7 +180,7 @@ export interface BidStore { byLimitOrder(limitOrderId: string, limit?: number): Promise /** Newest first. Implementations should cap `limit` at a few hundred. */ recent(limit?: number): Promise - /** Failed bids, newest first — for debugging. */ + /** Bids no venue took (neither Hyperbridge nor the orderbook), newest first — for debugging. */ failed(limit?: number): Promise byDateRange(from: Date, to: Date): Promise stats(): Promise diff --git a/sdk/packages/simplex/src/strategies/fx.ts b/sdk/packages/simplex/src/strategies/fx.ts index 61dc09690c..2ea33cea29 100644 --- a/sdk/packages/simplex/src/strategies/fx.ts +++ b/sdk/packages/simplex/src/strategies/fx.ts @@ -959,7 +959,13 @@ export class FXFiller implements FillerStrategy { } } - if (onHyperbridge) { + if (onHyperbridge && orderbookHoldsBid(onOrderbook)) { + // Live on the orderbook, so a swapper can still execute it; Hyperbridge's refusal is worth knowing, not an outage. + this.logger.warn( + { commitment, error: onHyperbridge.error, pending: onHyperbridge.pending }, + "Hyperbridge did not take the bid; the orderbook holds it", + ) + } else if (onHyperbridge) { this.logger.error( { commitment, error: onHyperbridge.error, pending: onHyperbridge.pending, orderbook: onOrderbook?.kind }, "Bid submission failed", diff --git a/sdk/packages/simplex/src/tests/cli/filler-toml-validate.test.ts b/sdk/packages/simplex/src/tests/cli/filler-toml-validate.test.ts index 7befdf8b79..bce3b517d7 100644 --- a/sdk/packages/simplex/src/tests/cli/filler-toml-validate.test.ts +++ b/sdk/packages/simplex/src/tests/cli/filler-toml-validate.test.ts @@ -53,16 +53,18 @@ describe("validateConfig", () => { expect(() => validateConfig(config)).not.toThrow() }) - it("rejects a missing substratePrivateKey", () => { + // A solver without a Hyperbridge key bids on the orderbook alone. + it("accepts a config without a substratePrivateKey or hyperbridgeWsUrl", () => { const config = minimalConfig() - config.simplex.substratePrivateKey = "" - expect(() => validateConfig(config)).toThrow(/substratePrivateKey is required/) + config.simplex.substratePrivateKey = undefined + config.simplex.hyperbridgeWsUrl = undefined + expect(() => validateConfig(config)).not.toThrow() }) - it("rejects a missing hyperbridgeWsUrl", () => { + it("rejects a hyperbridgeWsUrl missing while a substratePrivateKey is set", () => { const config = minimalConfig() config.simplex.hyperbridgeWsUrl = "" - expect(() => validateConfig(config)).toThrow(/hyperbridgeWsUrl is required/) + expect(() => validateConfig(config)).toThrow(/hyperbridgeWsUrl is required when simplex.substratePrivateKey is set/) }) it("loads an older config that still carries [[pairs]]", () => { diff --git a/sdk/packages/simplex/src/tests/core/bid-venues.test.ts b/sdk/packages/simplex/src/tests/core/bid-venues.test.ts index 2b6cf623a9..dc12f3e7cd 100644 --- a/sdk/packages/simplex/src/tests/core/bid-venues.test.ts +++ b/sdk/packages/simplex/src/tests/core/bid-venues.test.ts @@ -70,7 +70,7 @@ describe("BidVenues", () => { expect(gate.spent).toHaveBeenCalledOnce() }) - it("posts to the orderbook alone when the account holds no BRIDGE, and says so once", async () => { + it("posts to the orderbook alone when the account holds no BRIDGE", async () => { const { venues, submitBid, postBid, gate } = venuesWith({ canPlaceBid: false }) expect(await venues.place(COMMITMENT, USER_OP, BID)).toEqual({ orderbook: ACCEPTED }) @@ -79,8 +79,29 @@ describe("BidVenues", () => { expect(submitBid).not.toHaveBeenCalled() expect(postBid).toHaveBeenCalledTimes(2) expect(gate.spent).not.toHaveBeenCalled() - expect(logger.warn).toHaveBeenCalledTimes(1) - expect(logger.warn.mock.calls[0][1]).toMatch(/orderbook alone/) + }) + + it("posts to the orderbook before the balance read answers, so a slow endpoint holds up Hyperbridge alone", async () => { + let answer: (can: boolean) => void = () => undefined + const gate = { + canPlaceBid: () => new Promise((resolve) => (answer = resolve)), + spent: vi.fn(), + exhausted: vi.fn(), + } + const postBid = vi.fn(async () => ACCEPTED) + const submitBid = vi.fn(async () => LANDED) + const venues = new BidVenues( + { hyperbridge: { submitBid } as never, orderbook: { submitBid: postBid } as never, bridgeBalance: gate as never }, + logger as never, + ) + + const placing = venues.place(COMMITMENT, USER_OP, BID) + await new Promise((resolve) => setImmediate(resolve)) + expect(postBid).toHaveBeenCalledOnce() + expect(submitBid).not.toHaveBeenCalled() + + answer(true) + expect(await placing).toEqual({ hyperbridge: LANDED, orderbook: ACCEPTED }) }) it("zeroes the balance reading when Hyperbridge refuses the bid for want of BRIDGE", async () => { @@ -125,13 +146,20 @@ describe("BidVenues", () => { expect((await venues.place(COMMITMENT, USER_OP, BID)).orderbook).toBe(rejected) }) - it("without a Hyperbridge connection posts to the orderbook alone, without a word about balance", async () => { - const warnings = logger.warn.mock.calls.length + it("without a Hyperbridge connection posts to the orderbook alone", async () => { const postBid = vi.fn(async () => ACCEPTED) const venues = new BidVenues({ orderbook: { submitBid: postBid } as never }, logger as never) expect(await venues.place(COMMITMENT, USER_OP, BID)).toEqual({ orderbook: ACCEPTED }) - expect(logger.warn.mock.calls.length).toBe(warnings) + }) + + it("does not take a stale nonce for an empty balance", async () => { + const outdated: BidSubmissionResult = { success: false, error: "1010: Invalid Transaction: Transaction is outdated" } + const { venues, gate } = venuesWith({ hyperbridge: outdated }) + + await venues.place(COMMITMENT, USER_OP, BID) + + expect(gate.exhausted).not.toHaveBeenCalled() }) }) @@ -206,9 +234,39 @@ describe("BridgeBalanceGate", () => { expect(await gate.canPlaceBid()).toBe(true) }) - it("lets bids through to Hyperbridge when the balance cannot be read", async () => { - const { hyperbridge } = hyperbridgeWith({ free: 0n, failReads: true }) - expect(await new BridgeBalanceGate(hyperbridge, logger as never).canPlaceBid()).toBe(true) + it("lets bids through to Hyperbridge when the balance cannot be read, and reads again only after the TTL", async () => { + let now = 1_000_000 + const { hyperbridge, reads } = hyperbridgeWith({ free: 0n, failReads: true }) + const gate = new BridgeBalanceGate(hyperbridge, logger as never, () => now) + + expect(await gate.canPlaceBid()).toBe(true) + expect(await gate.canPlaceBid()).toBe(true) + expect(reads.account).toBe(1) + + now += BRIDGE_BALANCE_TTL_MS + 1 + expect(await gate.canPlaceBid()).toBe(true) + expect(reads.account).toBe(2) + }) + + it("says once that bids go to the orderbook alone, and once when the balance is back", async () => { + const state = { free: 0n } + let now = 1_000_000 + const { hyperbridge } = hyperbridgeWith(state) + const log = { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() } + const gate = new BridgeBalanceGate(hyperbridge, log as never, () => now) + + expect(await gate.canPlaceBid()).toBe(false) + expect(await gate.canPlaceBid()).toBe(false) + expect(log.warn).toHaveBeenCalledTimes(1) + expect(log.warn.mock.calls[0][1]).toMatch(/orderbook alone/) + + state.free = COST * 10n + now += BRIDGE_BALANCE_TTL_MS + 1 + expect(await gate.canPlaceBid()).toBe(true) + expect(await gate.canPlaceBid()).toBe(true) + expect(log.info).toHaveBeenCalledTimes(1) + expect(log.info.mock.calls[0][0]).toMatch(/restored/) + expect(log.warn).toHaveBeenCalledTimes(1) }) it("reads once for concurrent bids", async () => { diff --git a/sdk/packages/simplex/src/tests/core/orderbook-bids.test.ts b/sdk/packages/simplex/src/tests/core/orderbook-bids.test.ts index 9f48c33f99..6c3dc7fc6f 100644 --- a/sdk/packages/simplex/src/tests/core/orderbook-bids.test.ts +++ b/sdk/packages/simplex/src/tests/core/orderbook-bids.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it, vi } from "vitest" import { bytes20ToBytes32, type HexString } from "@hyperbridge/sdk" +import { BridgeBalanceGate, type BidPlacer } from "@/core/bid-venues" import { IntentFiller } from "@/core/filler" import { MemoryDataStore } from "@/data/memory" import type { SubmitBidResult } from "@/orderbook/types" @@ -39,15 +40,32 @@ const ACCEPTED: SubmitBidResult = { }, } +const USER_OP = "0x0102" as HexString +const logger = { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() } + describe("a bid the orderbook holds", () => { - /** A filler whose strategy answers every order with `result`. */ - function fillerWith(result: Omit) { + /** + * A filler whose strategy answers every order with `result`, or, given `through`, places + * one signed op through the venues the filler hands it and answers with what they said. + */ + function fillerWith(result: Omit | { through: true }) { const cache = new CacheService() const strategy = { name: "test", canFill: async () => true, calculateProfitability: async () => 1, - executeOrder: async () => ({ ...result, commitment: COMMITMENT, bid: BID }), + executeOrder: async (_order: unknown, venues?: BidPlacer) => { + if (!("through" in result)) return { ...result, commitment: COMMITMENT, bid: BID } + const placed = await venues!.place(COMMITMENT, USER_OP, BID) + return { + success: placed.hyperbridge?.success === true, + txHash: placed.hyperbridge?.extrinsicHash, + error: placed.hyperbridge?.error, + orderbook: placed.orderbook, + commitment: COMMITMENT, + bid: BID, + } + }, getOrderUsdValue: async () => ({ inputUsd: { toNumber: () => 1 } }), } const bids = new MemoryDataStore().bids @@ -56,10 +74,13 @@ describe("a bid the orderbook holds", () => { [strategy as never], { maxConcurrentOrders: 1 } as never, { + loggers: { get: () => logger }, getHyperbridgeWsUrl: () => undefined, getSubstratePrivateKey: () => undefined, getIntentGatewayAddress: () => "0xGATE", getRpcUrls: () => ["https://rpc.example"], + getTargetGasUnits: () => 1n, + getSimplexPaymasterAddress: () => "0x0000000000000000000000000000000000000001", } as never, {} as never, { cacheService: cache } as never, @@ -70,14 +91,63 @@ describe("a bid the orderbook holds", () => { ) const executed: { success: boolean; error?: string }[] = [] filler.monitor.on("orderExecuted", (event: { success: boolean; error?: string }) => executed.push(event)) - const order = { id: "0xorder", source: "EVM-1", destination: "EVM-1" } - const run = async () => { - ;(filler as any).executeOrder(order, strategy, true, { toNumber: () => 1 }, 1) + const run = async (id = "0xorder") => { + ;(filler as any).executeOrder({ id, source: "EVM-1", destination: "EVM-1" }, strategy, true, { toNumber: () => 1 }, 1) await (filler as any).chainQueues.get(1).onIdle() } - return { bids, executed, run } + return { filler, bids, executed, run } } + /** A Hyperbridge whose account holds `free`, for the gate to read. */ + function hyperbridgeHolding(free: bigint, submitBid = vi.fn(async () => ({ success: true, extrinsicHash: "0xext" }))) { + const api = { + query: { + system: { account: async () => ({ data: { free: { toString: () => free.toString() } } }) }, + intentsCoprocessor: { storageDepositFee: async () => ({ toString: () => "0" }) }, + }, + consts: { + intentsCoprocessor: { storageDepositFee: { toString: () => "1000" } }, + balances: { existentialDeposit: { toString: () => "100" } }, + }, + tx: { intentsCoprocessor: { placeBid: () => ({ paymentInfo: async () => ({ partialFee: { toString: () => "10" } }) }) } }, + } + return { queryApi: async () => api, getKeyPair: () => ({ address: "5Filler" }), submitBid } + } + + it("warns once across bids while the account holds no BRIDGE, and posts each to the orderbook", async () => { + const { filler, bids, run } = fillerWith({ through: true }) + const hyperbridge = hyperbridgeHolding(0n) + const postBid = vi.fn(async () => ACCEPTED) + ;(filler as any).hyperbridge = Promise.resolve(hyperbridge) + ;(filler as any).bridgeBalance = new BridgeBalanceGate(hyperbridge as never, logger as never) + filler.setOrderbook({ submitBid: postBid } as never) + logger.warn.mockClear() + + await run("0xorder-1") + await run("0xorder-2") + + expect(postBid).toHaveBeenCalledTimes(2) + expect(hyperbridge.submitBid).not.toHaveBeenCalled() + expect(logger.warn.mock.calls.filter((call) => /orderbook alone/.test(call[1]))).toHaveLength(1) + expect((await bids.byCommitments([COMMITMENT])).map((row) => row.onOrderbook)).toEqual([true, true]) + }) + + it("without a Hyperbridge key still delegates the SolverAccount and bids on the orderbook alone", async () => { + const { filler, bids, executed, run } = fillerWith({ through: true }) + const setupDelegationOnChains = vi.fn(async () => ({ success: true, results: { "EVM-1": true } })) + ;(filler as any).delegation = () => ({ setupDelegationOnChains }) + const postBid = vi.fn(async () => ACCEPTED) + filler.setOrderbook({ submitBid: postBid } as never) + + await (filler as any).setupSolverSelection(["EVM-1"], { requireAll: true }) + expect(setupDelegationOnChains).toHaveBeenCalledWith(["EVM-1"]) + + await run() + expect(postBid).toHaveBeenCalledWith(USER_OP) + expect(await bids.byCommitment(COMMITMENT)).toMatchObject({ success: false, onOrderbook: true }) + expect(executed).toEqual([expect.objectContaining({ success: true })]) + }) + it("is stored as live on the orderbook, counted as placed, and left to expire there", async () => { const { bids, executed, run } = fillerWith({ success: false, orderbook: ACCEPTED }) await run() diff --git a/sdk/packages/simplex/src/tests/data/bid-store.test.ts b/sdk/packages/simplex/src/tests/data/bid-store.test.ts index 34fe5cbe99..631d7e0227 100644 --- a/sdk/packages/simplex/src/tests/data/bid-store.test.ts +++ b/sdk/packages/simplex/src/tests/data/bid-store.test.ts @@ -169,19 +169,24 @@ for (const backend of backends) { expect((await bids.byCommitment(OTHER))!.bid).toBeNull() }) - it("counts bids by state", async () => { + it("counts bids by state, with a bid the orderbook alone holds as placed", async () => { const bids = backend.create() await bids.store({ commitment: COMMITMENT, success: true }) await bids.store({ commitment: OTHER, success: false, error: "nope" }) + // No BRIDGE for the deposit, but the orderbook took it: a swapper can execute it. + await bids.store({ commitment: THIRD, success: false, onOrderbook: true, error: "InsufficientBalance" }) await bids.markRetracted(COMMITMENT, null) expect(await bids.stats()).toEqual({ - total: 2, - successful: 1, + total: 3, + successful: 2, failed: 1, retracted: 1, + // The orderbook holds no deposit, so there is nothing to reclaim from it. pendingRetraction: 0, }) + expect((await bids.failed()).map((bid) => bid.commitment)).toEqual([OTHER]) + expect((await bids.unretractedReclaimable()).map((bid) => bid.commitment)).toEqual([]) }) it("finds the bids a limit order priced, newest first", async () => { diff --git a/sdk/packages/simplex/ui/src/wizard/Wizard.tsx b/sdk/packages/simplex/ui/src/wizard/Wizard.tsx index ac9bb01566..fa3497cded 100644 --- a/sdk/packages/simplex/ui/src/wizard/Wizard.tsx +++ b/sdk/packages/simplex/ui/src/wizard/Wizard.tsx @@ -64,10 +64,10 @@ export function signerRequirements(state: WizardState): string[] { return fields.filter(([, value]) => !value.trim()).map(([label]) => `Enter the Turnkey ${label.toLowerCase()}.`) } +/** The Hyperbridge account is optional: without one the solver bids on the orderbook alone. With one, bids need an endpoint. */ function substrateRequirements(state: WizardState): string[] { const issues: string[] = [] - if (!state.substrateKey.trim()) issues.push("Generate an account or enter an existing recovery phrase or hex seed.") - if (!state.hyperbridgeWsUrl.trim().startsWith("ws")) { + if (state.substrateKey.trim() && !state.hyperbridgeWsUrl.trim().startsWith("ws")) { issues.push("Enter a Hyperbridge WebSocket URL beginning with ws:// or wss://.") } return issues diff --git a/sdk/packages/simplex/ui/src/wizard/state.ts b/sdk/packages/simplex/ui/src/wizard/state.ts index b8bd385d79..72d57b62eb 100644 --- a/sdk/packages/simplex/ui/src/wizard/state.ts +++ b/sdk/packages/simplex/ui/src/wizard/state.ts @@ -214,8 +214,10 @@ export function assembleConfig(state: WizardState, defaults: SetupDefaults): Fil // The form collects plain strings; the config type wants the signer union / hex addresses. signer: signer as FillerConfig["simplex"]["signer"], maxConcurrentOrders: defaults.maxConcurrentOrders, - substratePrivateKey: state.substrateKey.trim(), - hyperbridgeWsUrl: state.hyperbridgeWsUrl.trim(), + // Omitted without a key: the solver then bids on the orderbook alone. + ...(state.substrateKey.trim() + ? { substratePrivateKey: state.substrateKey.trim(), hyperbridgeWsUrl: state.hyperbridgeWsUrl.trim() } + : {}), }, orderbook: { url: state.orderbook?.url ?? DEFAULT_ORDERBOOK_URLS.mainnet }, chains: chains.map((c) => ({ rpcUrls: c.rpcUrls.map((u) => u.trim()).filter(Boolean) })), diff --git a/sdk/packages/simplex/ui/src/wizard/steps/Substrate.tsx b/sdk/packages/simplex/ui/src/wizard/steps/Substrate.tsx index 32ce8664da..eafa73ecc6 100644 --- a/sdk/packages/simplex/ui/src/wizard/steps/Substrate.tsx +++ b/sdk/packages/simplex/ui/src/wizard/steps/Substrate.tsx @@ -109,7 +109,7 @@ export function StepSubstrate({ state, setState }: StepProps) { aria-labelledby="existing-account-title" >

Existing account

-

Import the account you already use for Hyperbridge.

+

Import the account you already use for Hyperbridge. Leave both empty to bid on the orderbook alone.