Executor grammar — usage guide

Companion to: executor-command-grammar.md (the why and how it’s built) and ADR-029 (the decision record). This page is the how to use it.

Crate: degenbot-executor. All examples compile against rust/crates/foundation/degenbot-executor/.

The production entry point: encode_cmd_stream

The bot builds a command stream for an arbitrage path with one function:

use degenbot_executor::composers::{
    encode_cmd_stream, config_for_options, EncodeOptions, HopInfo, PathInfo, V2HopInfo,
};

let cmd_bytes = encode_cmd_stream(
    &path.path_info,            // the ordered hops (PathInfo)
    path.optimal_input,         // entry capital (u128)
    &path.hop_outputs,          // per-hop forward outputs (solver-computed)
    &path.consumed_inputs,      // per-hop executable input (CL-clamped)
    ctx.executor_address,      // the cmd_executor contract
    ctx.pool_manager_address,   // the Uniswap V4 PoolManager
    ctx.weth_address,           // WETH9
    path.opts,                  // EncodeOptions — the per-path axes
);

encode_cmd_stream routes the path:

  • all-V2 any-N → grammar_shape::derive_all_v2 — the any-N flash-borrow + chained V2_SWAP_CALC Plan layout (one producer: the facts_of_all_v2 arm of build_walk; the distinct all-V2 3-hop layout and the N-hop speedrail were deleted in 4JOWO5, collapsing onto this layout).

  • every other 2/3-hop family → grammar::encode_grammar → grammar_shape::derive_shape — one recognized_key gate, then the single facts-driven build_walk pipeline (facts_for → derive_plan → the shape modules → LedgerValidator gate → plan_to_bytes). Again: no hand-written backstop.

encode_cmd_3_hop survives only as a #[doc(hidden)] test shim over encode_grammar — it produces no distinct byte layout.

Returns None if the family is unknown or any enc_* step fails (e.g. a u128 that does not fit the contract’s uint96/int128 range — guard with fits_int128 upstream).

The per-path axes: EncodeOptions

EncodeOptions carries the runtime economic choices the strategy/operator makes per path (ADR-029 D1). All default to inactive/custody:

use degenbot_executor::grammar_ledger::{
    Bribe, FundingSource, ProfitCapture,
};

let opts = EncodeOptions {
    erc6909_profit: false,                      // legacy alias for capture=Erc6909
    use_v4_batch: false,                         // bundle pure-V4 swaps in one PM extcall
    funding: FundingSource::SelfFund,            // executor holds the entry WETH
    capture: ProfitCapture::Erc6909,             // mint profit as an ERC6909 claim
    bribe: Bribe::Some { bips: 50, recipient_idx: 0 }, // 0.5% to block.coinbase
};

Field

Type

Honored today

funding

FundingSource

yes — SelfFund on V2-led paths; InPathFlash default. Modeled on others.

capture

ProfitCapture

yes — routes to check_mode via config_for_options.

bribe

Bribe

yes — routes to bribe_bips/recipient_idx via config_for_options.

erc6909_profit

bool

legacy alias; forces ProfitCapture::Erc6909 via resolve_axes.

use_v4_batch

bool

yes — V4_BATCH for pure-V4 paths (single PM extcall).

resolve_axes(opts) collapses the legacy erc6909_profit bool into capture (backwards-compatible: erc6909_profit: true forces Erc6909 regardless of the capture field; leaving it false and setting capture directly is honored).

The config builder: config_for_options

execute(commands, config) takes a packed uint256 config. Build it from the same EncodeOptions:

let execute_config = config_for_options(path.opts, U256::ZERO);

config_for_options reads the full axis set:

  • capture → check_mode (ADR-029, U3WVLL):

    • Custody/Native/Owner/BalancerVault → check_mode=1 (WETH+ETH combined-balance assert — active by default, the on-chain money-loss protection).

    • Erc6909 → check_mode=2 (ERC6909 WETH claim).

    • SweepToAddress → check_mode=3 (SWEEP — defeats the assert for the rare “send accumulated profit to another address” case).

  • bribe → bribe_bips + bribe_recipient_idx (None = (0, 0); Some{bips, recipient_idx} is forwarded — recipient_idx 0 = block.coinbase).

  • expected_value is IGNORED (kept in the signature for ABI compat): the U3WVLL contract fix made the executor read its own combined balance at start+end, so the operator no longer supplies the pre-tx balance.

Defect history (U3WVLL). The old default was check_mode=0 — the profit assert was skipped by default, a footgun that let a money-losing tx execute silently. config_for_options makes check_mode=1 the default; a money-losing production path now reverts on-chain. The sweep opt-in (check_mode=3, task 767TN5) is the sole explicit defeat path.

For low-level packing (or to bypass the axis builder), see degenbot_executor::encoders::pack_config(check_mode, expected_value, bribe_bips, bribe_recipient_idx) in config.rs.

The ABI wrap: encode_execute_call

Wrap the command stream in the execute(bytes, uint256) ABI call:

use degenbot_executor::composers::{encode_execute_call, EncodedCall};

let call: EncodedCall = encode_execute_call(
    ctx.executor_address,
    &cmd_bytes,
    execute_config,
).expect("ABI encode");
// call.to / call.data / call.value — ready for submission.

Putting it together — the production call site

This is exactly what degenbot-arbitrage::simulator does (task Q35IJN):

// 1. Encode the cmd_executor command stream (YQORTM).
let cmd_bytes = encode_cmd_stream(
    &path.path_info,
    path.optimal_input,
    &path.hop_outputs,
    &path.consumed_inputs,
    ctx.executor_address,
    ctx.pool_manager_address,
    ctx.weth_address,
    path.opts,
)?;
// 2. Build the axis-aware config (Q35IJN).
let execute_config = config_for_options(path.opts, U256::ZERO);
// 3. Wrap in execute(bytes, uint256).
let execute_calldata = encode_execute_call(ctx.executor_address, &cmd_bytes, execute_config)?;

See rust/crates/engine/degenbot-arbitrage/src/simulator.rs (~build_execute_tx) for the full production path, including the pre/post balance reads that prove profitability.

Building a PathInfo

A PathInfo is the ordered hop descriptors:

use degenbot_executor::composers::{HopInfo, PathInfo, V2HopInfo, V3HopInfo, V4HopInfo};

let path = PathInfo::new(vec![
    HopInfo::V2(V2HopInfo {
        pool_address, token0_address, token1_address, fee, zfo,
    }),
    HopInfo::V3(V3HopInfo {
        pool_address, token0_address, token1_address, fee, zfo,
    }),
    HopInfo::V4(V4HopInfo {
        pool_manager_address, pool_id_hex,
        currency0_address, currency1_address,
        fee, tick_spacing, hook_address, zfo,
    }),
]);

The HopInfo variants carry exactly the fields the encoder needs — pool address, direction (zfo = zero-for-one), fee, and the token0/token1 (or currency0/1) addresses. V3HopInfo.fee is informational (V3 fees are encoded in the pool address). V4HopInfo carries the PoolManager address, the pool_id_hex, the pool-key fields (fee uint24, tick_spacing int24, hook_address), and the currencies. v4_input_is_native / v4_output_is_native classify the native-ETH representation gap that CurrencyBridge resolves at a V4↔X boundary.

The currency bridge (native-ETH ↔ WETH)

V4 tracks native ETH and WETH as distinct delta currencies. When a path’s hop A outputs one and hop B’s input expects the other, an explicit WETH_DEPOSIT (wrap) or WETH_WITHDRAW (unwrap) must bridge the gap inside V4_UNLOCK before hop B runs:

use degenbot_executor::composers::CurrencyBridge;

let bridge = CurrencyBridge::at_boundary(output_currency_a, input_currency_b);
if bridge.needs_bridge() {
    let (take_idx, settle_idx) = bridge.bridge_indices(weth_idx, native_idx);
    // emit V4_TAKE_COMPACT(take_idx → SELF) + (WETH_DEPOSIT | WETH_WITHDRAW)
}

CurrencyBridge::None (both sides agree) needs no bridge. The walker emits the bridge steps automatically at V4↔X boundaries from the facts’ currency pairing (the V4TakeCompact + WETH_DEPOSIT/WETH_WITHDRAW sequence — see grammar_shape::v4_bridge_steps, used by the shape modules).

Validating a Plan directly (the invariant gate)

The validator is the reason the grammar exists — it makes the two bug classes unrepresentable. Author/build a Plan, project it, and validate:

use degenbot_executor::grammar_ledger::{LedgerValidator, LedgerOp};

let mut v = LedgerValidator::default();
for op in &ops {               // ops: Vec<LedgerOp> from plan_to_ledger_ops(&plan)
    v.push(*op)?;              // fails on the FIRST credit-before-debit violation
}
v.finish()?;                   // every flash debt must be repaid

or the all-in-one convenience:

v.validate_full(&ops)?;        // push all + finish

The error variants name the invariant that fired:

ValidationError

The invariant

The bug class it kills

TakeBeforeCredit

PM[currency] ≥ amount before a take/mint

pre-fix v2_v2_v4 / v2_v4_v4

SwapCalcBeforeCredit

pair seeded before V2_SWAP_CALC

2PT5HH / path-182449 über-draw

Erc20TransferBeforeCredit

Erc20[currency] ≥ amount before a debit

V2/V3 flash-repay-before-credit

FlashDebtUnpaid

flash debt zero at finish()

unpaid in-path flash

PmDeltaNonzero

every PM[cur] nets to zero at V4UnlockEnd

unresolved PM delta

NativeTransferBeforeCredit

Native ≥ amount before a native pay-in

native-settle gap

For external ledgers (the additive-capability proof, ADR-029 D6), construct the validator with the external ledgers and the ExternalFlash/ExternalRepay ops route to them via the BalanceLedger trait:

use degenbot_executor::grammar_ledger::{LedgerValidator, ExternalLedger};

let v = LedgerValidator::default()
    .with_external_ledgers(vec![ExternalLedger::default()]); // index 0 = a Balancer-shaped Vault

The Plan tree (advanced — supporting a new family)

A new family is no longer authored as code per family. The authoring surface is:

  1. Add the facts row in grammar_walker::facts_for — the per-hop HopFacts: protocol variety, direction (zfo), pool/fee/spacing data, the coupling axes the family exercises (out_dest, repay, and — only where a consumer shape body reads it — terminal_form / repay_mechanism / seed_delivery).

  2. If the enclosure is new, teach a shape body. The 3-hop topology rules (rule_walk_v2v3 / rule_walk_v4_led / rule_walk_v2v3_v4_mixed in grammar_walker/shapes/three_hop.rs) derive the nesting from the facts’ debt-flow threading (rules 1–3, stated on the walker fns in grammar_walker/shapes/three_hop.rs). A genuinely new DEX primitive lands as one constructor in grammar_walker::mod mechanics.

  3. The validator is the gate. derive_shape_detailed runs the LedgerValidator on every produced Plan; an ordering defect is a fatal Rejected (ADR-030), never silently dropped — so the new family either produces an invariant-clean Plan or produces nothing. Byte-identity / execution grounds come from the golden suites + the revm runtime matrix.

The Plan tree is an execution-ordered, callback-nested tree whose leaves carry BOTH the resolved address-table index (for the byte encoder) and the currency/pool address (for the LedgerOp projection). Inspecting one directly:

use degenbot_executor::grammar_plan::{plan_to_bytes, plan_to_ledger_ops};
use degenbot_executor::grammar_walker::build_walk;

let (preamble, plan, at) = build_walk(&path, &inputs)?;
// Same Plan → two consumers, no drift:
let mut bytes = preamble;
bytes.extend_from_slice(&plan_to_bytes(&plan, &at));
let ops: Vec<_> = plan_to_ledger_ops(&plan);   // feed to LedgerValidator

A PlanStep::FlashSwap { callback, auto_repay, .. } nests its callback subtree (the bytes that fire when the swap runs); auto_repay=true models the empty-callback V2/V3 flash whose in_currency the contract auto-pays from the executor’s balance at callback-end. V4Unlock { inner, .. } nests its unlock callback and emits V4UnlockEnd after it. Depth-first walk = execution order.

The full PlanStep variant set lives in rust/crates/foundation/degenbot-executor/src/grammar_plan.rs; the axis vocabulary a new family can consume is documented on the axis enums in rust/crates/foundation/degenbot-executor/src/grammar_walker.rs (TerminalForm, RepayMechanism, SeedDelivery — each field’s doc comment states when it is set and who consumes it) and in the CONTEXT.md walker glossary.

Where to look next

  • The architecture doc: executor-command-grammar.md — the why and the invariant model.

  • The decision records: ADR-029 (the axes, the hybrid, the additive proof), ADR-030 (the derive tri-state), ADR-031 (the facts-driven walker).

  • The walker lineage: executor-walker-spike.md (the spike that de-risked the schema) and ADR-031’s Resolution paragraph (the T1–T6 decomposition record; the standalone ledger doc was removed in the stale-docs cleanup 71ec78b2).

  • The V4 ledger rules: grammar_ledger.rs — the PM net-zero-at-unlock-close invariant (the V4 master rule, stated on the V4 ledger ops).

  • The Plan-tree decision: executor-command-grammar.md §”What ‘derived’ means here (the 6ZIE5X decision, realized)” — why the Plan tree (mechanism (iii)) over byte-decoding / enc_*-instrumentation.

  • The model record: ADR-029 (the axes + open-ledger model) and executor-command-grammar.md (the realized architecture that the model plan doc preceded).

  • The tests of record: tests/composers_parity.rs, tests/composers_3hop_parity.rs (golden-master byte-parity), rust/crates/engine/degenbot-simulation/tests/harness_declarative.rs (the runtime matrix), and the rule_walker_shadows_* unit tests in grammar_walker/shapes/three_hop.rs (post-cutover pinning of the current enclosure per topology-rule group).