ADR-025: The ExecutionAdapter seam — a deep, user-owned execution layer over the thin engine¶
Status: accepted. In response to a Candidate-1 architecture review of the
composer explosion in degenbot-executor/src/composers.rs, grilling reframed
the repository’s actual friction: the execution side of degenbot is overfit to
one searcher’s cmd_executor contract (the 27-way three_hop_* permutation
fan-out + the dead V4V4ArbitragePayload/V4V3ArbitragePayload/
CmdExecutorComposer payload builders + the 7-call success/failure balance
gate). The fix is not just to collapse those functions — it is to give the
execution side a real seam so an arbitrary user can turn a solver result
into a payload for their own contract, and define their own simulation
success/failure gate, without being wedged into the developer’s executor or the
backrun strategy’s 7-call balance bundle.
Context¶
Two first-class consumers exist per AGENTS.md — a pure-Rust MEV bot and a Python-driven bot that is a thin driver shell (Rust is the engine; Python is a cockpit, not a co-implementation). degenbot must not force either consumer onto any one searcher’s executor contract design.
Today the execution side is overfit. degenbot-executor/src/composers.rs
(189 KB) is a shallow module: its public entry encode_cmd_stream fans out
to 27 three_hop_* + 8 encode_cmd_* permutation bodies (V2/V3/V4 × …), each
hand-rolling the address-table setup, native↔WETH bridge, CL-clamp intake, and
enc_* primitive calls. Adding a 4th DEX family would multiply the surface
(64 + 16). On top of that, V4V4ArbitragePayload, V4V3ArbitragePayload, and
CmdExecutorComposer have zero production callers — they survive only via
the byte-parity tests in tests/composers_parity.rs. And the success/failure
gate is the backrun strategy’s 7-call pre/post-balance bundle (WETH9
balanceOf / Multicall3 getEthBalance / PoolManager ERC6909) — inseparable
from one funding model and one executor contract.
The overfitting concern was stated explicitly: “I don’t want to force any user
to use my particular design on the execution side — this layer needs to support
an arbitrary user transforming a solver result into a payload compatible with
their contract.” That declared requirement is the forcing function that
makes the seam real (the codebase’s two-adapter rule + “revisit only on a
forcing function” discipline from the multicall3-batch/ArcSwap dispositions).
It also refines ADR-019, which already established that the backrun 7-call
bundle + decode_balance + compute_priority_fee are searcher code, out of
scope for the thin degenbot-simulation engine.
Decision¶
D1 — degenbot-execution owns the generic foreign-adapter seam.¶
The dedicated, pyo3-free degenbot-execution crate owns the generic
ExecutionAdapter / PayloadComposer traits and their value types: the
solve-result view, ComposerInputs, the probe/assess protocol, and
ExecutionResult. Its ComposerInputs deliberately contains solver-driven
amounts and adapter-agnostic options only; it does not carry command-executor
addresses or EncodeOptions. The dependency direction remains a DAG, with
pyo3 confined to the binding layer.
The concrete production cmd_executor adapter is separate and lives in
degenbot-strategy, not in this generic seam. CmdExecutorAdapter is the
built-in adapter for the developer’s cmd_executor contract, while a foreign
searcher implements the generic seam in its own crate. This preserves the
original requirement that an arbitrary user can target a contract without
depending on the built-in strategy.
D2 — The strategy decomposes into four obvious parts.¶
Ergonomics for a Python user with no Rust background (the Polars map_elements
ideal — “a simple blob of code that just works”) are achieved by splitting the
strategy into parts that are either user code or declared data:
Encode — a function / blob:
solve result → payload bytes. This is thePayloadComposerseam (compose(path, inputs) -> Bytes). Rust users implement it; Python users supply a callable.Probe — declared data: which pre/post read-calls to snapshot
(label, addr, selector). The engine runs the reads / warm cache / AL.Assess — a gate rule: how deltas → gross and pass/fail. Built-in shapes (sum-of-deltas, return-value) + an optional tiny user interpreter.
Fee — the defaulted pricing half of Assess, not a fifth seam.
compute_priority_feeis already strategy-side (ADR-019); it stays a built-in market-percentile default (TARGET_PROFIT_RATIO / age-decay), overridable by a foreign searcher.
Only the genuinely variable logic (payload encoding, settlement interpretation) is user code; the mechanical parts (probes, fee) are data/defaults. Net profit is defined in terms of the pricing policy, so pricing is not independently orderable — it is folded into Assess.
D3 — The built-in command-executor path is strategy-owned and typed.¶
degenbot-strategy::CmdExecutorAdapter is the production built-in adapter for
the canonical cmd_executor path. It captures the strategy-owned session
ExecutionContext (the executor, authoritative V4 PoolManager, and WETH
addresses) at construction. Backrun boot builds that context once and gives
the same value to frame simulation and V4 descriptor/roster projection. Each
adapter call accepts PathInfo, SolveResult, and a per-call EncodeOptions;
the options are deliberately not added to the generic ComposerInputs.
The adapter returns CmdExecutorOutcome::Encoded(Bytes),
CmdExecutorOutcome::Declined(CmdExecutorDecline), or
CmdExecutorOutcome::Rejected(CmdExecutorRejection). The five caller-facing
JSONL decline labels are preserved exactly: unsupported_hop_shape,
amount_exceeds_uint96, encoding_failed:cmd_stream,
encoding_failed:execute_call, and mixed_pool_managers. A
CmdExecutorRejection::LedgerValidation is always fatal under ADR-030 and is
never collapsed into a routine decline. The adapter owns the production
composition boundary; the lower-level degenbot-executor grammar and ABI
primitives remain implementation details beneath it.
D4 — The solve-result view protocol.¶
The seam’s input is SolvePathResult (amounts: optimal_input /
hop_outputs / consumed_inputs) + PathInfo (hop descriptors), projected to
Python as a typed SolveResult view — because today the per-hop amounts do not
cross to Python on the clean path (SimResult carries pre-built
execute_calldata, not the amounts). This is the one genuinely new surface;
both consumer types stay symmetric (Rust uses the same two types directly).
D5 — The production adapter is the hard cutover boundary.¶
The landed hard cutover routes canonical strategy composition through
CmdExecutorAdapter and its typed outcome. The retired helper names
compose_candidate, build_candidate_calldata, and ComposeReject are not
compatibility aliases and do not exist in canonical strategy callers or tests.
A mechanical umbrella architecture gate scans the strategy src/ and
tests/ trees for those names, while a compile-time umbrella test pins the
public paths degenbot::CmdExecutorAdapter and
degenbot::strategy::CmdExecutorAdapter.
The generic foreign-adapter surface remains available unchanged: Rust users
implement ExecutionAdapter / PayloadComposer with their own contract, and
Python users continue to supply a callable through the existing PyO3 lift.
The production adapter is a concrete strategy implementation, not a new
ComposerInputs policy or a second generic seam.
Considered options (rejected)¶
Enum-only deepen (no outer seam). The overfitting concern is a declared second adapter, so the two-adapter rule justifies the seam; enum-only leaves foreign users forced onto
cmd_executor. Rejected.A
SimGatehook insidedegenbot-simulation. Re-wedges strategy into the thin engine — the exact ADR-019 consequence the repo forbids. Rejected in favor of D2/D3 (engine keeps generic probe/execute/decode; the user supplies a thin probe-spec + gate, never a free-form sim-loop hook).Seq only-shared-helpers internal collapse. Improves texture but doesn’t solve the overfit / don’t-wedge concern. Rejected.
See also¶
Execution strategy — user guide — the Polars-style Encode blob + Probe declared reads + Assess options + the solve-result view protocol, with both the Rust and Python plug-in points and the concrete sample references.
Consequences¶
A Rust user
impl ExecutionAdapterorPayloadComposerin their own crate; a Python user passes a callable + probe/assess spec via the existing PyO3 lift. Both foreign paths meet the same generic seam indegenbot-execution.The built-in production adapter and its typed session context are reachable from the umbrella as
degenbot::CmdExecutorAdapteranddegenbot::ExecutionContext, and from the strategy namespace underdegenbot::strategy; the adapter uses per-callEncodeOptions.degenbot-executorremains the low-level command grammar and ABI support;degenbot-strategyowns the canonical production adapter boundary. The generic execution contract remains “solve result +degenbot.abi”.Canonical strategy behavior is routed through the typed
Encoded/Declined/Rejectedoutcomes and the five preserved caller labels; ledger-validation rejection remains fatal.pyo3stays out of all core crates; the lift lives indegenbot-python.The generic seam stays foreign-adapter-shaped, while the hard cutover keeps the retired command-executor helper names out of canonical strategy callers and tests. This refines ADR-019, ADR-005, and ADR-015 without adding a compatibility layer.