Skip to content

Execution engine

Shared server-side route selection for executor-driven swaps. Both the CSIP cron and the Autopilot cron use it, so both get the same battle-tested behaviour.

Source: bz-backend/services/executionEngine.js
Extracted: verbatim from dcaCron.getSwapData on 2026-07-24

Two exported functions, and every line of both exists because something broke in production.

Why it exists separately

Interactive swaps go through the relayer, where a user watches the result and a failure is visible immediately. Executor-driven swaps do not: the vault contract measures the balance delta inside the transaction and reverts if it is short, and nobody is watching.

That difference makes three things mandatory for the executor path that the interactive path can be relaxed about:

  1. The route must actually be a swap, not an approval.
  2. The route must deliver output synchronously, in the same transaction.
  3. The minimum-output floor must be loose enough not to revert on rounding, and tight enough not to leak value.

getBestExecutableRoute

js
const route = await getBestExecutableRoute({
  amount,                // human units of fromToken
  fromChain, toChain,    // Network docs
  fromToken, toToken,    // Token docs
  srcWalletAddress,      // user wallet — quote context only
  dstWalletAddress,      // ⚠ the EXECUTING CONTRACT, never the user
  slippage = 0.5,
  topN = 5,
  probe,                 // caller-supplied on-chain simulation gate
})
// → { tx: {to, data, value}, spender, amount /* min-out floor */, quoteMeta }

dstWalletAddress must be the vault, not the user

Output has to land in the executing contract so the vault can measure its own balance delta and credit the position. Pointing it at the user's wallet means the vault sees zero delta and reverts — or worse, on a contract without the delta check, credits nothing while the funds have moved.

The pipeline

Guard 1 — top-N fallback

js
for (const q of quotes.slice(0, topN)) { try { … } catch { continue } }

Aggregator transaction-data endpoints return 500s intermittently — Odos is notably flaky. Without a fallback, one bad response stalls the entire cron cycle and every due position misses its slot.

Guard 2 — the calldata guard

js
const to = tx?.tx?.to?.toLowerCase?.()
if (!to || to === fromToken?.address?.toLowerCase())
  throw new Error(`Not swap calldata (target=${tx?.tx?.to})`)
if (((tx?.tx?.data?.length || 2) - 2) / 2 <= 68)
  throw new Error('Calldata too short to be a swap (approve step?)')

Some providers return an approve() step as their transaction data: tx.to is the sell token and the calldata is 68 bytes (4-byte selector + two 32-byte words). A contract that "executes" that has done nothing, and — in DCA V1, which credited a supplied number rather than a measured delta — credited output that never arrived, leaving claims underfunded.

The 68-byte check alone is bypassable

Calldata can be padded past 68 bytes while still being an approval. That is exactly the weakness that made the Autopilot vault's data.length > 68 guard insufficient, and why the vault gained an explicit router allow-list. Here the tx.to == fromToken check is the stronger half.

Guard 3 — adaptive minimum-output floor

js
const expectedOut = Number(q?.amount || 0)
const isMicro = Number(q?.usdAmount || 0) > 0 && Number(q.usdAmount) < 5
const minOut = expectedOut * (isMicro ? 0.985 : 0.995)
Order sizeTolerance
≥ $50.5%
< $51.5%

Micro orders have large quote-versus-fill variance because of pool tick granularity, and they repeatedly reverted at 0.5%. Widening costs fractions of a cent in absolute terms. Real-size orders keep the tight floor.

Post-V2 this floor costs the user nothing

Both vaults now credit the measured balance delta, so minOut is a revert guard only. Under DCA V1 — which credited returnTokenValue — every point of buffer was a direct price leak to the user. That is the single most important reason the V2 upgrade mattered.

Guard 4 — the probe

js
if (probe && !(await probe(candidate))) {
  throw new Error('route delivers no in-tx output (async/solver fill)')
}

The caller supplies the probe, because only the caller knows which contract function will execute the route. In practice it is an estimateGas against the vault's batch function with a single item:

js
await contract.estimateGas.batchExecuteDeposit([singleItem])   // DCA
await contract.estimateGas.batchExecuteTrades([singleItem])    // Autopilot

This is what rejects intent and solver providers:

ProviderBehaviourResult
RelayReturns token-transfer calldata; a solver fills laterZero in-transaction delta → OutputBelowMinimum → probe fails
NordsternCalldata selector 0x57f96440…, asynchronous fillSame

Those are perfectly good providers for an interactive bridge, where the user watches a status poll. They are unusable for a contract that must measure output atomically.

gasOverrides

js
export async function gasOverrides(provider) {
  const gasPrice = (await provider.getGasPrice()).mul(2)
  return { gasPrice }
}

Legacy gasPrice, deliberately, with a 2× buffer.

Do not let ethers auto-fill EIP-1559 fees here

Ethers' default auto-fill uses a 1.5 gwei priority tip, which is roughly 77× Arbitrum's real gas price. The executor must reserve gasLimit × maxFeePerGas, so that tip inflated the required balance enormously and made the executor look permanently out of gas. Using the network gasPrice with a 2× buffer absorbs a base-fee tick between fetch and broadcast, and unused headroom is never charged.

After this fix, an Arbitrum batch costs roughly 0.00002 ETH.

Who calls it

dcaCron.js

tick (every minute, CRON != 'false')
  → read DcaInvestment where status: 'success'
  → self-heal sync from chain (processed, status, depositTime, interval)
  → filter to due positions
  → group per chain
  → getBestExecutableRoute({ …, dstWalletAddress: DCA_ADDRESS, probe })
  → batchExecuteDeposit([...]) with gasOverrides
  → record DcaFill (unique txHash + positionId)
  → processAutoClaims()

Per-position try/catch writes lastError and lastAttemptAt onto the DcaInvestment document (cleared on success, shown as an amber "retrying" state in the UI), so one bad position no longer kills the batch.

autopilotCron.js

tick
  → autopilotSync: mirror on-chain AgentAccount state into Mongo
  → for each active agent:
      → autopilotEngine.evaluateAgent(doc, ctx)   // returns an intended trade or null
      → RugCheck safety gate (skip trusted majors; block unscored)
      → getBestExecutableRoute({ …, dstWalletAddress: AUTOPILOT_ADDRESS, probe })
      → collect into a batch
  → batchExecuteTrades([...]) with gasOverrides
  → notifications

Failure-isolation summary

LevelMechanism
One aggregator's quote failsSwallowed inside AggregatorFactory
One aggregator's build failsTop-N fallback to the next quote
A route is not a real swapCalldata guard
A route fills asynchronouslyProbe
One position/agent fails entirelyPer-item try/catch, error recorded on the document
One item in a batch reverts on-chainbatchExecuteTrades isolates it (Autopilot); batchExecuteDeposit per-item handling (DCA)

Known limits

  • topN = 5 is a silent cap. If all five best routes fail, the position skips this tick with no distinction between "no liquidity" and "five flaky endpoints".
  • The probe is optional. A caller that omits it gets no solver protection. Both current callers supply one.
  • The calldata guard's byte check is padding-bypassable on its own.
  • getQuotes is called with type: 'SWAP' — same-chain only. Cross-chain automation would need a different path.
  • No caching between ticks. Every cron tick re-quotes from scratch, which is correct for freshness but means a busy tick does a lot of provider fan-out.

Internal + developer documentation. Usage figures are point-in-time snapshots — re-verify before publishing them.