Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sdk/packages/simplex-desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@hyperbridge/simplex-desktop",
"productName": "Simplex",
"version": "0.18.2",
"version": "0.18.3",
"private": true,
"description": "Native desktop shell for the Simplex intent solver",
"author": "Polytope Labs <hello@polytope.technology>",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 2026-10-22 — An order's evaluation lives through its confirmations, and is redone if it expires first

`IntentFiller` evaluates an order while it waits for the order's source confirmations, then queues
its bids on the destination chain. Everything the evaluation caches (the gas estimate, the bid plans,
the filler outputs) expired 60 s after it was written, and the bid loop's pin
(`2026-10-20-bid-loop-outlives-the-evaluation-cache.md`) keeps only what is still live when the loop
starts. A confirmation wait longer than that left the loop with no plans and no gas estimate. Every
bid then failed in `prepareBidUserOp` with `No cached gas estimate found for order <id>`, and the
order was never evaluated again. Seen on a BSC order confirmed 79 s after its evaluation.

## Cache lifetime

- Every `CacheService` entry keyed by an order (gas estimate, bid plans, filler outputs and inputs,
funding prepends, partial-fill and fee-check flags, matched limit orders, swap operations, pair
classifications) stores its own `expiresAt`, set when it is written.
- The default lifetime, `CACHE_EXPIRY_MS`, is 300 s.
- While an order waits for its source confirmations, `handleNewOrder` registers how long they take
with `CacheService.extendForConfirmations(orderId, waitMs)`. The wait is the required confirmations
times the source chain's block time from `ContractInteractionService.blockTimeSeconds`, measured
once at startup. Entries written in that time live the default plus the wait.
- The wait is released once the evaluation and the confirmations are both done. Entries keep the
expiry they were written with; later entries get the default.
- With no block time to be had, the wait is 0 and the order keeps the default lifetime.
- A pinned order's entries are judged as of the pin, as before: live then means live for the whole
bid loop.

## Re-evaluation

`handleNewOrder` also hands `executeOrder` a re-evaluation of the order (`evaluateOrder` with the
same `canFill` answers). Confirmations slower than the block time predicted, or a long chain-queue
wait, can still outlast an evaluation. So when the queued task starts, it pins the order and checks
the gas estimate: the first thing an evaluation caches, and what every bid signs.

- If the gas estimate is gone, the order is evaluated again before any bid goes out, and the bids use
the strategy and plans that evaluation chooses.
- A re-evaluation that passes on the order sends no bid, and reports why as any evaluation does
(`orderSkipped`). The expired evaluation is never used.
- An evaluation still live when the task starts is used as is, so every bid on an order is priced by
a single evaluation.

`@hyperbridge/simplex` and `simplex-desktop` are 0.18.3.
2 changes: 1 addition & 1 deletion sdk/packages/simplex/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hyperbridge/simplex",
"version": "0.18.2",
"version": "0.18.3",
"license": "Apache-2.0",
"description": "Automated intent filler for the Hyperbridge IntentGateway — run it as a binary or embed it as a library",
"main": "dist/index.js",
Expand Down
52 changes: 47 additions & 5 deletions sdk/packages/simplex/src/core/filler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,18 @@ export class IntentFiller {
return
}

// The bids are signed from what the evaluation caches, after the confirmations it runs
// beside, so that lives as long as the confirmations take on the source chain on top of
// the cache's own lifetime. Entries keep the expiry they were written with, so the wait
// is released once the evaluation is over.
const releaseWait =
order.id && requiredConfirmations > 0
? this.contractService.cacheService.extendForConfirmations(
order.id,
await this.confirmationWaitMs(order.source, requiredConfirmations),
)
: undefined

// Run confirmation waiting and evaluation in parallel.
// The AbortController lets evaluateOrder cancel the confirmation
// loop early when the order turns out to be unprofitable.
Expand Down Expand Up @@ -1298,7 +1310,7 @@ export class IntentFiller {
if (!result) abortController.abort()
return result
}),
])
]).finally(() => releaseWait?.())
const confirmDurationSec = (Date.now() - confirmStartMs) / 1000
this.monitor.emit("orderTiming", {
orderId: order.id,
Expand Down Expand Up @@ -1340,6 +1352,7 @@ export class IntentFiller {
solverSelectionActive,
inputUsdValue,
evaluationResult.profitability,
() => this.evaluateOrder(order, canFillCache),
)
} catch (error) {
this.logger.error({ orderId: order.id, err: error }, "Error processing order")
Expand Down Expand Up @@ -1453,6 +1466,20 @@ export class IntentFiller {
return validStrategies[0]
}

/**
* How long `confirmations` take on `chain`, at the block time measured when simplex started.
* Zero when there is no block time to be had: the order then keeps the cache's own lifetime,
* and an evaluation that expires before its bids is redone.
*/
private async confirmationWaitMs(chain: string, confirmations: number): Promise<number> {
try {
return Math.ceil(confirmations * (await this.contractService.blockTimeSeconds(chain)) * 1000)
} catch (err) {
this.logger.warn({ chain, err }, "Could not read the block time to size the order's cache lifetime")
return 0
}
}

/**
* The destination chain's latest block number, the clock `order.deadline` is read on.
*
Expand Down Expand Up @@ -1501,6 +1528,7 @@ export class IntentFiller {
solverSelectionActive: boolean,
inputUsdValue: Decimal,
profitUsd: number,
reevaluate?: () => Promise<{ strategy: FillerStrategy; profitability: number } | null>,
): void {
// Get the chain-specific queue
const chainQueue = this.chainQueues.get(getChainId(order.destination)!)
Expand All @@ -1518,6 +1546,24 @@ export class IntentFiller {
const queueDurationSec = (Date.now() - queuedAtMs) / 1000
this.monitor.emit("orderTiming", { orderId: order.id, phase: "queue_wait", durationSec: queueDurationSec })

const cache = this.contractService.cacheService
// The evaluation these bids were priced by stays alive until the last of them is
// sent: a slow submission would otherwise expire the gas estimate the rest sign.
unpin = order.id ? cache.pinOrder(order.id) : undefined
// The pin keeps only what is still live, and confirmations slower than the block time
// predicted, or this queue, can outlast the evaluation. Its gas estimate, the first thing
// it caches and what every bid signs, is then gone along with everything cached after it,
// so the order is evaluated again before any bid goes out: every bid on it is still
// priced by one evaluation, and a fresh one.
if (order.id && reevaluate && !cache.getGasEstimate(order.id)) {
this.logger.info({ orderId: order.id }, "Order evaluation expired before bidding, evaluating it again")
const evaluation = await reevaluate()
// The evaluation reported why it passed on the order.
if (!evaluation) return
bestStrategy = evaluation.strategy
profitUsd = evaluation.profitability
}

this.logger.info(
{ orderId: order.id, strategy: bestStrategy.name, chain: order.destination },
"Executing order",
Expand All @@ -1529,10 +1575,6 @@ export class IntentFiller {
// limit-order priced it) still sends the one bid it always did. Nothing is
// held against the limit order: its on-chain budget refuses a fill past its
// cap, in the same calldata as the fill.
const cache = this.contractService.cacheService
// The evaluation these bids were priced by stays alive until the last of them is
// sent: a slow submission would otherwise expire the gas estimate the rest sign.
unpin = order.id ? cache.pinOrder(order.id) : undefined
const plans = this.limitOrders && order.id ? cache.getBidPlans(order.id) : []
let lastResult: FillResult | undefined
// Each bid's nonce key binds its own calldata, so every bid is the first
Expand Down
Loading
Loading