Appearance
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:
- The route must actually be a swap, not an approval.
- The route must deliver output synchronously, in the same transaction.
- 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 size | Tolerance |
|---|---|
| ≥ $5 | 0.5% |
| < $5 | 1.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]) // AutopilotThis is what rejects intent and solver providers:
| Provider | Behaviour | Result |
|---|---|---|
| Relay | Returns token-transfer calldata; a solver fills later | Zero in-transaction delta → OutputBelowMinimum → probe fails |
| Nordstern | Calldata selector 0x57f96440…, asynchronous fill | Same |
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
→ notificationsFailure-isolation summary
| Level | Mechanism |
|---|---|
| One aggregator's quote fails | Swallowed inside AggregatorFactory |
| One aggregator's build fails | Top-N fallback to the next quote |
| A route is not a real swap | Calldata guard |
| A route fills asynchronously | Probe |
| One position/agent fails entirely | Per-item try/catch, error recorded on the document |
| One item in a batch reverts on-chain | batchExecuteTrades isolates it (Autopilot); batchExecuteDeposit per-item handling (DCA) |
Known limits
topN = 5is 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.
getQuotesis called withtype: '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.