Skip to main content

degenbot_executor/
composers.rs

1//! 2-hop + N-hop command-stream path composers.
2//!
3//! The composer layer combines the primitive `enc_*` opcode builders from
4//! [`crate::encoders`] into a complete `cmd_executor` command-stream `bytes`
5//! payload per path type, returning [`None`] on unsupported or failing paths.
6//!
7//! ## Sign conventions (§10.2)
8//!
9//! * V3 `amountSpecified` is a positive `uint96` exact-input (the contract
10//!   negates it internally).
11//! * V4 compact amounts are positive `uint96`; the contract negates for
12//!   exact-input direction.
13//!
14//! ## Native ETH / WETH (§10.3)
15//!
16//! `NATIVE_CURRENCY_ADDRESS` (`address(0)`) is the V4 native-ETH currency.
17//! WETH and native ETH are distinct PM delta currencies — when a path crosses
18//! the WETH↔native boundary, an explicit `WETH_DEPOSIT` (wrap) or
19//! `WETH_WITHDRAW` (unwrap) bridges the representation gap inside `V4_UNLOCK`.
20
21// composers.rs — arbitrage path encoders for the `cmd_executor` contract.
22//
23// 2-hop and 3-hop composers all take a `&ComposerInputs` bundle beyond the
24// hops, so none trips `too_many_arguments`.
25
26// `emit_currency_bridge` (the only user of these) survives RVNIPD only as a
27// unit-test fixture.
28#[cfg(test)]
29use crate::encoders::{self, SENTINEL_SELF};
30use alloy::primitives::{Address, U256};
31use degenbot_abi::abi_types::AbiValue;
32use degenbot_abi::encoder::encode_rust;
33
34/// `NATIVE_CURRENCY_ADDRESS` — V4's native-ETH currency is `address(0)`.
35///
36/// Mirrors `degenbot.uniswap.v4_liquidity_pool.NATIVE_CURRENCY_ADDRESS` and
37/// the encoders' [`NATIVE_ADDRESS`] (the same `Address::ZERO`).
38pub const NATIVE_CURRENCY_ADDRESS: Address = Address::ZERO;
39
40/// The `execute(bytes,uint256)` 4-byte function selector
41/// (`keccak256("execute(bytes,uint256)")[:4]` = `0xab5898e8`).
42///
43/// The 4-byte selector for `execute(bytes,uint256)`, `0xab5898e8`.
44pub const EXECUTE_SELECTOR: [u8; 4] = [0xab, 0x58, 0x98, 0xe8];
45
46// ═══════════════════════════════════════════════════════════════════════════
47// Hop descriptors + PathInfo
48// ═══════════════════════════════════════════════════════════════════════════
49
50/// Engine-facing hop descriptor — the Rust mirror of the Python
51/// `V2HopInfo`/`V3HopInfo`/`V4HopInfo` dataclasses in
52/// `src/degenbot/arbitrage/hop_info.py`.
53#[derive(Clone, Debug)]
54pub enum HopInfo {
55    /// A Uniswap-V2 (or V2-compatible) pool hop.
56    V2(V2HopInfo),
57    /// A Uniswap-V3 pool hop.
58    V3(V3HopInfo),
59    /// A Uniswap-V4 pool hop.
60    V4(V4HopInfo),
61}
62
63/// V2 hop descriptor — `pool_address`, `token0/1_address`, `fee` (bips of
64/// 10000, e.g. 30 = 0.3%), `zfo` (zero-for-one direction).
65#[derive(Clone, Debug)]
66pub struct V2HopInfo {
67    pub pool_address: Address,
68    pub token0_address: Address,
69    pub token1_address: Address,
70    pub fee: u16,
71    pub zfo: bool,
72}
73
74/// V3 hop descriptor — `pool_address`, `token0/1_address`, `fee` (bips of
75/// 1e6, e.g. 3000 = 0.3%), `zfo`.
76///
77/// `fee` is informational only — V3 fees are encoded in the pool address,
78/// not the command stream.
79#[derive(Clone, Debug)]
80pub struct V3HopInfo {
81    pub pool_address: Address,
82    pub token0_address: Address,
83    pub token1_address: Address,
84    pub fee: u32,
85    pub zfo: bool,
86}
87
88/// V4 hop descriptor — `pool_manager_address`, `pool_id_hex` (0x-prefixed),
89/// `currency0/1_address`, `fee` (uint24), `tick_spacing` (int24), `hook_address`,
90/// `zfo`.
91#[derive(Clone, Debug)]
92pub struct V4HopInfo {
93    pub pool_manager_address: Address,
94    pub pool_id_hex: String,
95    pub currency0_address: Address,
96    pub currency1_address: Address,
97    pub fee: u32,
98    pub tick_spacing: i32,
99    pub hook_address: Address,
100    pub zfo: bool,
101}
102
103/// An arbitrage path's ordered hops.
104#[derive(Clone, Debug)]
105pub struct PathInfo {
106    /// Ordered hops; the V2/V3/V4 mix in `hops` selects the composer.
107    pub hops: Vec<HopInfo>,
108}
109
110impl PathInfo {
111    /// Construct from an ordered hop slice.
112    #[must_use]
113    pub fn new(hops: Vec<HopInfo>) -> Self {
114        Self { hops }
115    }
116}
117
118// ═══════════════════════════════════════════════════════════════════════════
119// V4 native helpers
120// ═══════════════════════════════════════════════════════════════════════════
121
122/// True if the V4 swap's **input** currency is native ETH (`address(0)`).
123///
124/// `zfo=True` ⇒ input is `currency0`; `zfo=False` ⇒ input is `currency1`.
125#[must_use]
126pub fn v4_input_is_native(hop: &V4HopInfo) -> bool {
127    let input_currency = if hop.zfo {
128        hop.currency0_address
129    } else {
130        hop.currency1_address
131    };
132    input_currency == NATIVE_CURRENCY_ADDRESS
133}
134
135/// True if the V4 swap's **output** currency is native ETH (`address(0)`).
136///
137/// `zfo=True` ⇒ output is `currency1`; `zfo=False` ⇒ output is `currency0`.
138#[must_use]
139pub fn v4_output_is_native(hop: &V4HopInfo) -> bool {
140    let output_currency = if hop.zfo {
141        hop.currency1_address
142    } else {
143        hop.currency0_address
144    };
145    output_currency == NATIVE_CURRENCY_ADDRESS
146}
147
148// ═══════════════════════════════════════════════════════════════════════════
149// Currency-bridge helpers (native-ETH ↔ WETH representation gap)
150// ═══════════════════════════════════════════════════════════════════════════
151
152/// The representation-bridge action needed at a V4↔X currency boundary.
153///
154/// V4 tracks native ETH and WETH as distinct delta currencies. When a
155/// path's hop A outputs one and hop B's input expects the other, an explicit
156/// `WETH_DEPOSIT` (wrap native→WETH) or `WETH_WITHDRAW` (unwrap WETH→native)
157/// must bridge the gap inside `V4_UNLOCK` before hop B runs. See §10.3 of the
158/// crate docs + `executor/tests/test_cmd_executor_v4v4_wrap_unwrap.py`
159/// for the canonical on-chain pattern.
160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161pub enum CurrencyBridge {
162    /// No bridge — both sides agree (both native or both WETH/ERC20).
163    None,
164    /// V4 output is native ETH, hop B needs WETH → `V4_TAKE_COMPACT(native)` + `WETH_DEPOSIT`.
165    Wrap,
166    /// V4 output is WETH, hop B needs native ETH → `V4_TAKE_COMPACT(weth)` + `WETH_WITHDRAW`.
167    Unwrap,
168}
169
170impl CurrencyBridge {
171    /// `true` when a wrap or unwrap is required at this boundary.
172    #[must_use]
173    pub const fn needs_bridge(self) -> bool {
174        !matches!(self, Self::None)
175    }
176
177    /// Classify the boundary from the mid-currencies of two adjacent hops.
178    ///
179    /// `output_currency_a` is the currency hop A *delivers* (its output
180    /// currency); `input_currency_b` is the currency hop B *consumes* (its
181    /// input currency). Only native-ETH (`address(0)`) vs anything-else is
182    /// the distinguishing axis — WETH addresses and other ERC-20s are all
183    /// "non-native" from the bridge's perspective.
184    #[must_use]
185    pub fn at_boundary(output_currency_a: Address, input_currency_b: Address) -> Self {
186        let a_native = output_currency_a == NATIVE_CURRENCY_ADDRESS;
187        let b_native = input_currency_b == NATIVE_CURRENCY_ADDRESS;
188        match (a_native, b_native) {
189            (true, false) => Self::Wrap,
190            (false, true) => Self::Unwrap,
191            _ => Self::None,
192        }
193    }
194
195    /// The address-table indices a bridge boundary needs: `(take_idx,
196    /// settle_idx)`.
197    ///
198    /// `take_idx` is the currency to `V4_TAKE_COMPACT` *from* the PoolManager
199    /// (the source side of the representation gap: native for `Wrap`, WETH
200    /// for `Unwrap`). `settle_idx` is the currency to `V4_SETTLE_DELTA` *into*
201    /// the PoolManager after the downstream swap runs (the consumed side:
202    /// WETH for `Wrap`, native for `Unwrap`) — the swap debited the opposite
203    /// representation, so the executor settles the one it now holds.
204    ///
205    /// Call only when [`needs_bridge`] is true; for [`Self::None`] both
206    /// indices are `0` (unused). `weth_idx` / `native_idx` are the
207    /// address-table sentinels (typically `SENTINEL_WETH` / `SENTINEL_NATIVE`).
208    ///
209    /// [`needs_bridge`]: Self::needs_bridge
210    #[must_use]
211    pub const fn bridge_indices(self, weth_idx: u8, native_idx: u8) -> (u8, u8) {
212        match self {
213            Self::None => (0, 0), // caller guards `needs_bridge()`
214            // Wrap: take native out, deposit as WETH, swap consumes WETH → settle WETH.
215            Self::Wrap => (native_idx, weth_idx),
216            // Unwrap: take WETH out, withdraw to native, swap consumes native → settle native.
217            Self::Unwrap => (weth_idx, native_idx),
218        }
219    }
220}
221
222/// Emit the `V4_TAKE_COMPACT` + `WETH_DEPOSIT`/`WETH_WITHDRAW` bridge bytes
223/// for a [`CurrencyBridge`] into `inner`.
224///
225/// `currency_idx` is the address-table index of the currency to take from
226/// the PoolManager: the native-ETH index (`SENTINEL_NATIVE` or a registered
227/// table entry) for [`CurrencyBridge::Wrap`], or the WETH index
228/// (`SENTINEL_WETH`) for [`CurrencyBridge::Unwrap`]. `amount` is the
229/// forward output hop A produced (the quantity to wrap or unwrap).
230///
231/// Returns `None` (for `?` propagation) only if `V4_TAKE_COMPACT` fails to
232/// encode (uint96 overflow — the walker's int128 guard bounds the amounts).
233/// [`CurrencyBridge::None`] emits nothing.
234#[cfg(test)]
235pub(crate) fn emit_currency_bridge(
236    inner: &mut Vec<u8>,
237    bridge: CurrencyBridge,
238    currency_idx: u8,
239    amount: u128,
240) -> Option<()> {
241    match bridge {
242        CurrencyBridge::None => {}
243        CurrencyBridge::Wrap => {
244            inner.extend_from_slice(
245                &encoders::enc_v4_take_compact(currency_idx, SENTINEL_SELF, amount).ok()?,
246            );
247            inner.extend_from_slice(&encoders::enc_weth_deposit(U256::from(amount)));
248        }
249        CurrencyBridge::Unwrap => {
250            inner.extend_from_slice(
251                &encoders::enc_v4_take_compact(currency_idx, SENTINEL_SELF, amount).ok()?,
252            );
253            inner.extend_from_slice(&encoders::enc_weth_withdraw(U256::from(amount)));
254        }
255    }
256    Some(())
257}
258
259// ═══════════════════════════════════════════════════════════════════════════
260// Top-level dispatcher
261// ═══════════════════════════════════════════════════════════════════════════
262
263/// Tuning knobs for [`encode_cmd_stream`]. All default to `false`/`0`.
264///
265/// **Per-path output axes (ADR-029 D1, WE45KC):** `funding`, `capture`, and
266/// `bribe` carry the runtime economic choices the strategy/operator makes per
267/// path. Whether a family's builder actually branches an axis IN THE STREAM is
268/// **declared per family** on the family→producer dispatch row
269/// ([`crate::grammar_shape::family_axis_support`]) — read that, not builder
270/// bodies. Today: `funding` is branched only by `v2_v3` + any-N all-V2 (their
271/// rows declare `{funding}`); every other family derives it (`InPathFlash`).
272/// `capture` is branched only by the pure-V4 families `v4_v4` / `v4_v4_v4`
273/// (their rows declare `{capture}`); V2/V3-only and V4-involving-but-not-
274/// pure-V4 streams reach `capture` only via the on-chain `check_mode` config
275/// (a different seam), NOT the stream bytes. `bribe` is branched by no family
276/// (it rides `pack_config`, never the stream). Spreading an axis across more
277/// families is separate post-WE45KC work, not this surface's claim. The legacy
278/// `erc6909_profit` bool is kept as a backwards-compatible alias for
279/// `capture = ProfitCapture::Erc6909` (see [`resolve_axes`] for the precedence
280/// rule).
281#[derive(Clone, Copy, Debug, Default)]
282pub struct EncodeOptions {
283    /// If `true`, use `V4_MINT_COMPACT` instead of `V4_TAKE_DELTA` for profit
284    /// capture on pure-V4 paths (saves ~20K gas; needs `check_mode=2`).
285    /// Legacy alias for `capture = ProfitCapture::Erc6909` (see [`resolve_axes`]).
286    pub erc6909_profit: bool,
287    /// If `true`, use `V4_BATCH` instead of individual `V4_SWAP_COMPACT`/`_DYNAMIC`
288    /// for pure-V4 paths (single PM extcall).
289    pub use_v4_batch: bool,
290    /// Declared origin of the stream's entry (seed) capital (ADR-029 D1).
291    /// Branched IN THE STREAM only by the families whose dispatch row declares
292    /// `funding` ([`crate::grammar_shape::family_axis_support`]: `v2_v3` and
293    /// any-N all-V2); every other family derives `InPathFlash`. Honoring it as
294    /// a runtime economic knob across ALL families is separate post-WE45KC work.
295    pub funding: crate::grammar_ledger::FundingSource,
296    /// Declared destination of the stream's terminal profit (ADR-029 D1).
297    /// Honored via [`resolve_axes`] (takes precedence over the legacy
298    /// `erc6909_profit` bool only when that bool is `false`).
299    pub capture: crate::grammar_ledger::ProfitCapture,
300    /// Whether/how a builder bribe is paid (ADR-029 D1/Q3). Not yet honored by
301    /// the encoder; wiring lands in a subsequent WE45KC increment.
302    pub bribe: crate::grammar_ledger::Bribe,
303}
304
305/// Resolve the per-path output axes (ADR-029 D1, WE45KC) from [`EncodeOptions`],
306/// collapsing the legacy `erc6909_profit` bool into the `capture` axis.
307///
308/// **Precedence (backwards-compatible):** `erc6909_profit: true` forces
309/// `ProfitCapture::Erc6909` regardless of the `capture` field — so every
310/// existing caller that sets the legacy bool keeps today's bytes. A caller that
311/// leaves `erc6909_profit: false` (the default) and sets `capture` directly is
312/// honored.
313///
314/// `funding` and `bribe` are passed through unchanged (the encoder does not yet
315/// read them; they are carried for the subsequent WE45KC increments).
316#[must_use]
317pub fn resolve_axes(
318    opts: EncodeOptions,
319) -> (
320    crate::grammar_ledger::FundingSource,
321    crate::grammar_ledger::ProfitCapture,
322    crate::grammar_ledger::Bribe,
323) {
324    let capture = if opts.erc6909_profit {
325        crate::grammar_ledger::ProfitCapture::Erc6909
326    } else {
327        opts.capture
328    };
329    (opts.funding, capture, opts.bribe)
330}
331
332// ═══════════════════════════════════════════════════════════════════════════
333// Encode intake: EncodeContext (session) + EncodeRequest (per path, ADR-033)
334// ═══════════════════════════════════════════════════════════════════════════
335
336/// The session-scoped deployment addresses shared by every encode request in
337/// one session (ADR-033). Built once per session (the strategy), never per
338/// path.
339#[derive(Clone, Copy, Debug, PartialEq, Eq)]
340pub struct EncodeContext {
341    /// The `cmd_executor` contract the stream executes on.
342    pub executor: Address,
343    /// The Uniswap-V4 `PoolManager` (the pool-key / delta-claim home).
344    pub pool_manager: Address,
345    /// The session's WETH (the seed + wrap/unwrap bridge currency).
346    pub weth: Address,
347}
348
349impl EncodeContext {
350    #[must_use]
351    pub fn new(executor: Address, pool_manager: Address, weth: Address) -> Self {
352        Self {
353            executor,
354            pool_manager,
355            weth,
356        }
357    }
358}
359
360/// The per-path encode intake (ADR-033): the path + the solver's amount
361/// triple + the declared axes, as one unit.
362///
363/// Built exactly once at each producing site (the strategy's candidate
364/// projection; the declarative harness chain runners) and handed to
365/// [`encode_cmd_stream`] together with an [`EncodeContext`]. A request
366/// without its path is the shape that lets amounts be synthesized blind to
367/// what the path constrains — so path and amounts are one unit.
368///
369/// **The CL overfeed-clamp invariant (path-5000 EMPTY-HALT) attaches
370/// to this value**: `consumed_inputs[i]` is the *executable* input fed to hop
371/// `i`. For a non-over-fed CL hop (and for V2/Balancer/Curve/Solidly hops) it
372/// equals `hop_outputs[i − 1]`; for an over-fed CL hop the producer clamps it
373/// to `input_consumed − 1` (the solver's `clamp_cl_hop_capacity`, whose bound
374/// is the pools-layer `exact_input_clamp_bound` rule) so the on-chain
375/// exact-in loop terminates on `amountRemaining == 0` instead of marching
376/// empty bitmap words. Building the request is where that invariant is owned;
377/// it is not re-derived by the encoder.
378#[derive(Clone, Debug)]
379pub struct EncodeRequest {
380    /// The resolved path hops (the encode's shape selector).
381    pub path: PathInfo,
382    /// The flash/optimal input amount (u128; the `cmd_executor` int128
383    /// convention).
384    pub optimal_input: u128,
385    /// Per-hop output amounts. `hop_outputs[i]` = the output after hop `i`
386    /// (`[forward_out, final_output]` for a 2-hop path).
387    pub hop_outputs: Vec<u128>,
388    /// Per-hop executable input amounts (the CL-clamp swap-in — see the type
389    /// doc for the invariant).
390    pub consumed_inputs: Vec<u128>,
391    /// The declared per-path axes (funding / capture / bribe + the opcode
392    /// toggles).
393    pub opts: EncodeOptions,
394}
395
396impl EncodeRequest {
397    /// Build a request, checking the hop-alignment invariants.
398    ///
399    /// `hop_outputs` and `consumed_inputs` are per-hop arrays: each must have
400    /// exactly one entry per hop in `path`, or the encode would index
401    /// misaligned amounts silently.
402    ///
403    /// # Panics
404    ///
405    /// If `hop_outputs.len()` or `consumed_inputs.len()` differs from
406    /// `path.hops.len()` (a programmer error — the arrays are per-hop, aligned
407    /// with the path). The panic names the mismatched arrays.
408    #[must_use]
409    pub fn new(
410        path: PathInfo,
411        optimal_input: u128,
412        hop_outputs: Vec<u128>,
413        consumed_inputs: Vec<u128>,
414        opts: EncodeOptions,
415    ) -> Self {
416        let n = path.hops.len();
417        assert_eq!(
418            hop_outputs.len(),
419            n,
420            "EncodeRequest: hop_outputs has {} entries for a {}-hop path",
421            hop_outputs.len(),
422            n
423        );
424        assert_eq!(
425            consumed_inputs.len(),
426            n,
427            "EncodeRequest: consumed_inputs has {} entries for a {}-hop path",
428            consumed_inputs.len(),
429            n
430        );
431        Self {
432            path,
433            optimal_input,
434            hop_outputs,
435            consumed_inputs,
436            opts,
437        }
438    }
439}
440
441/// Bundled context every composer needs beyond the hops.
442///
443/// Built once per path (in [`encode_cmd_stream`] / [`encode_cmd_3_hop`]) and
444/// passed by reference to each composer, collapsing every signature to
445/// `(hops.., &ComposerInputs)` so no composer trips `too_many_arguments`.
446#[derive(Clone, Copy)]
447pub struct ComposerInputs<'a> {
448    pub executor_address: Address,
449    pub pool_manager_address: Address,
450    pub weth_address: Address,
451    pub optimal_input: u128,
452    pub hop_outputs: &'a [u128],
453    /// The per-hop executable input fed into each pool, as set by the solver's
454    /// CL-hop clamp (`consumed_inputs[i]`). For a non-over-fed CL hop (and for
455    /// V2/Curve/Balancer/Solidly hops) this equals `hop_outputs[i-1]`; for an
456    /// over-fed CL hop the clamp reduces it to `input_consumed - 1` so the
457    /// on-chain exact-in loop terminates on `amountRemaining == 0` instead of
458    /// marching empty bitmap words (path-5000 EMPTY-HALT).
459    pub consumed_inputs: &'a [u128],
460    pub opts: EncodeOptions,
461}
462
463/// Encode an arbitrage path as a `cmd_executor` command stream.
464///
465/// Produces a `bytes` payload for `execute(commands)` on the `cmd_executor`
466/// contract. Uses compact command encoding (`V2_SWAP_COMPACT`, `V2_SWAP_CALC`,
467/// `V4_SWAP_COMPACT`, …) with an address table for minimal calldata size.
468///
469/// The intake contract is the pair [`EncodeContext`] (session-scoped
470/// deployment addresses) + [`EncodeRequest`] (per path: the path + the
471/// solver's amount triple + the declared axes — ADR-033). The CL
472/// overfeed-clamp invariant attaches to the request (`consumed_inputs[i]`
473/// is the executable input to hop `i`) — it is owned where the request is
474/// built, not re-derived here. Bribes never ride the stream: the caller
475/// passes them through `pack_config` at the call site.
476///
477/// Returns `None` if encoding declines for this path type. A validator
478/// `Reject` (a Plan was built but violated the ledger invariants) is fatal
479/// by contract (ADR-030) — it panics rather than folding into `None`.
480///
481/// # Path-type routing
482///
483/// * all-V2 hops (≥2): [`crate::grammar_shape::derive_all_v2`] — the Plan +
484///   validator path (KO5NNB cutover)
485/// * every other 2/3-hop mix: the shape-class walker
486///   ([`crate::grammar_shape::derive_shape`])
487#[must_use]
488pub fn encode_cmd_stream(ctx: &EncodeContext, req: &EncodeRequest) -> Option<Vec<u8>> {
489    let num_hops = req.path.hops.len();
490    let inputs = ComposerInputs {
491        executor_address: ctx.executor,
492        pool_manager_address: ctx.pool_manager,
493        weth_address: ctx.weth,
494        optimal_input: req.optimal_input,
495        hop_outputs: &req.hop_outputs,
496        consumed_inputs: &req.consumed_inputs,
497        opts: req.opts,
498    };
499
500    // Facet A: a generic per-shape-class hop-grammar walk replaces the
501    // former 8 two-hop + 27 three-hop bespoke permutation bodies, producing
502    // byte-identical output (validated by the golden corpus). All-V2 any-N uses
503    // the Plan + validator path (`derive_all_v2` → `build_walk` → gate
504    // → `plan_to_bytes`, KO5NNB); other 2/3-hop paths use the combo grammar walk.
505    if num_hops >= 2 && req.path.hops.iter().all(|h| matches!(h, HopInfo::V2(_))) {
506        crate::grammar_shape::derive_all_v2(&req.path, &inputs)
507    } else {
508        crate::grammar_shape::derive_shape(&req.path, &inputs)
509    }
510}
511
512// ═══════════════════════════════════════════════════════════════════════════
513// ABI wrap: execute(bytes, uint256)
514// ═══════════════════════════════════════════════════════════════════════════
515
516/// A single EVM call ready for on-chain submission.
517#[derive(Clone, Debug, PartialEq, Eq)]
518pub struct EncodedCall {
519    /// Target contract address.
520    pub to: Address,
521    /// ABI-encoded calldata (selector + parameters).
522    pub data: Vec<u8>,
523    /// ETH value to send with the call.
524    pub value: U256,
525}
526
527/// Build the `execute(bytes,uint256)` `config` uint256 matching an
528/// [`EncodeOptions`] (the axis-aware config builder, WE45KC). Reads the full
529/// per-path axis set:
530///   - `capture` → `check_mode`: `Erc6909` = 2 (verify via PM.balanceOf),
531///     `SweepToAddress` = 3 (SWEEP — defeats the assert), every other capture
532///     (`Custody`/`Native`/`Owner`/`BalancerVault`) = 1 (WETH+ETH combined
533///     balance assert — active by default, U3WVLL). Resolved through
534///     [`resolve_axes`] so the legacy `erc6909_profit` bool is collapsed into
535///     `capture` (backwards-compatible: `erc6909_profit: true` forces
536///     `Erc6909`).
537///   - `bribe` → `bribe_bips` + `bribe_recipient_idx`: `None` = (0, 0) (no bribe);
538///     `Some{bips, recipient_idx}` is forwarded (recipient_idx 0 = block.coinbase).
539///   - `expected_value` is IGNORED (kept in the signature for ABI compat; the
540///     U3WVLL contract fix made the executor read its OWN combined balance at
541///     start+end, so the operator no longer supplies the pre-tx balance).
542///
543/// This is the single axis-aware config builder. Production
544/// (`degenbot-arbitrage`'s `simulate_path_on_evm`, Q35IJN) packs every
545/// `execute(bytes, uint256)` call through it, and the declarative harness
546/// (`run_path_with_*`, SMOZG3) mirrors it — so the on-chain profit check
547/// (check_mode 1/2/3) runs under production exactly as it runs under tests.
548/// Only the offline calldata-dump examples use the raw zero config.
549///
550/// # Errors
551///
552/// Returns [`crate::encoders::EncoderError`] if the resolved
553/// `bribe` axis is out of range (`bips > 10_000` or `recipient_idx >= 32`);
554/// `check_mode` is always in range (statically resolved from `ProfitCapture`).
555pub fn config_for_options(
556    opts: EncodeOptions,
557    expected_value: U256,
558) -> Result<U256, crate::config::ConfigError> {
559    let _ = expected_value; // U3WVLL: ignored — the contract reads its own balance.
560    let (_, capture, bribe) = resolve_axes(opts);
561    // U3WVLL defect fix: the profit assert is active by default. Non-erc6909
562    // captures use check_mode=1 (WETH+ETH combined balance assert — the
563    // on-chain money-loss protection the operator wants active "nearly
564    // always"); Erc6909 uses check_mode=2 (ERC6909 WETH). check_mode=0 (fast
565    // path, no assert) is no longer the default — it was the footgun that
566    // silently skipped the profit check.
567    let check_mode = match capture {
568        crate::grammar_ledger::ProfitCapture::Erc6909 => 2u8,
569        crate::grammar_ledger::ProfitCapture::SweepToAddress => 3u8,
570        _ => 1u8,
571    };
572    let (bribe_bips, bribe_recipient_idx) = match bribe {
573        crate::grammar_ledger::Bribe::None => (0u16, 0u8),
574        crate::grammar_ledger::Bribe::Some {
575            bips,
576            recipient_idx,
577        } => (bips, recipient_idx),
578    };
579    crate::config::pack_config(check_mode, U256::ZERO, bribe_bips, bribe_recipient_idx)
580}
581
582/// Wrap a command-stream `commands` payload in the `execute(bytes, uint256)`
583/// ABI call to the `cmd_executor` contract.
584///
585/// `config` is the packed `execute()` config uint256 (see
586/// [`config::pack_config`]); `0` = skip profit check, no bribe.
587///
588/// # Errors
589///
590/// Returns [`degenbot_abi::abi_types::AbiValue`] encoding errors (should not
591/// happen with valid inputs).
592pub fn encode_execute_call(
593    executor_address: Address,
594    commands: &[u8],
595    config: U256,
596) -> Result<EncodedCall, degenbot_core::errors::AbiDecodeError> {
597    let values = [
598        AbiValue::Bytes(commands.to_vec()),
599        AbiValue::Uint(config, 256),
600    ];
601    let encoded = encode_rust(&["bytes", "uint256"], &values)?;
602    let mut data = Vec::with_capacity(4 + encoded.len());
603    data.extend_from_slice(&EXECUTE_SELECTOR);
604    data.extend_from_slice(&encoded);
605    Ok(EncodedCall {
606        to: executor_address,
607        data,
608        value: U256::ZERO,
609    })
610}
611
612// ═══════════════════════════════════════════════════════════════════════════
613// 3-hop entry point (grammar-delegating, kept for API/tests)
614// ═══════════════════════════════════════════════════════════════════════════
615
616/// Encode a 3-hop arbitrage path as a `cmd_executor` command stream.
617///
618/// Facet A: delegates to the generic per-shape-class grammar walk
619/// ([`crate::grammar_shape`]), which dispatches to per-family hop adapters — the
620/// same byte-identical engine `encode_cmd_stream` now uses. Retained as a thin
621/// 3-hop convenience entry (public, `#[doc(hidden)]`) for callers/tests that
622/// previously reached the 27 `three_hop_*` dispatcher directly.
623///
624/// Returns `None` for an unknown combination or if any `enc_*` step fails.
625#[doc(hidden)]
626#[must_use]
627#[expect(clippy::too_many_arguments)] // 3-hop entry carries executor/pm/weth + opts (matches bespoke signature)
628pub fn encode_cmd_3_hop(
629    path_info: &PathInfo,
630    optimal_input: u128,
631    hop_outputs: &[u128],
632    consumed_inputs: &[u128],
633    executor_address: Address,
634    pool_manager_address: Address,
635    weth_address: Address,
636    opts: EncodeOptions,
637) -> Option<Vec<u8>> {
638    let inputs = ComposerInputs {
639        executor_address,
640        pool_manager_address,
641        weth_address,
642        optimal_input,
643        hop_outputs,
644        consumed_inputs,
645        opts,
646    };
647    crate::grammar_shape::derive_shape(path_info, &inputs)
648}
649
650// ═══════════════════════════════════════════════════════════════════════════
651// Unit tests — CurrencyBridge classifier + emitter
652// ═══════════════════════════════════════════════════════════════════════════
653
654#[cfg(test)]
655#[expect(clippy::unwrap_used, clippy::expect_used)]
656mod tests {
657    use super::*;
658    use crate::encoders::{self, AddressTable, SENTINEL_NATIVE, SENTINEL_SELF, SENTINEL_WETH};
659    use alloy::primitives::address;
660
661    #[test]
662    fn currency_bridge_both_native_is_none() {
663        let b = CurrencyBridge::at_boundary(NATIVE_CURRENCY_ADDRESS, NATIVE_CURRENCY_ADDRESS);
664        assert_eq!(b, CurrencyBridge::None);
665        assert!(!b.needs_bridge());
666    }
667
668    #[test]
669    fn currency_bridge_both_weth_is_none() {
670        let weth = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
671        let b = CurrencyBridge::at_boundary(weth, weth);
672        assert_eq!(b, CurrencyBridge::None);
673    }
674
675    #[test]
676    fn currency_bridge_native_to_weth_is_wrap() {
677        let weth = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
678        let b = CurrencyBridge::at_boundary(NATIVE_CURRENCY_ADDRESS, weth);
679        assert_eq!(b, CurrencyBridge::Wrap);
680        assert!(b.needs_bridge());
681    }
682
683    #[test]
684    fn currency_bridge_weth_to_native_is_unwrap() {
685        let weth = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
686        let b = CurrencyBridge::at_boundary(weth, NATIVE_CURRENCY_ADDRESS);
687        assert_eq!(b, CurrencyBridge::Unwrap);
688        assert!(b.needs_bridge());
689    }
690
691    #[test]
692    fn currency_bridge_native_to_erc20_is_wrap() {
693        // native → any non-native (ERC-20) is a wrap (executor holds ETH, needs WETH/ERC20 representation)
694        let usdc = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
695        let b = CurrencyBridge::at_boundary(NATIVE_CURRENCY_ADDRESS, usdc);
696        assert_eq!(b, CurrencyBridge::Wrap);
697    }
698
699    #[test]
700    fn currency_bridge_erc20_to_native_is_unwrap() {
701        let usdc = address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
702        let b = CurrencyBridge::at_boundary(usdc, NATIVE_CURRENCY_ADDRESS);
703        assert_eq!(b, CurrencyBridge::Unwrap);
704    }
705
706    #[test]
707    fn emit_currency_bridge_none_emits_nothing() {
708        let mut inner = Vec::new();
709        let result = emit_currency_bridge(&mut inner, CurrencyBridge::None, 0xFE, 1000);
710        assert_eq!(result, Some(()));
711        assert!(inner.is_empty(), "None bridge must emit zero bytes");
712    }
713
714    #[test]
715    fn emit_currency_bridge_wrap_emits_take_plus_deposit() {
716        let mut inner = Vec::new();
717        let native_idx = 0xFF;
718        let amount = 1_000_000_000_000_000_000u128;
719        emit_currency_bridge(&mut inner, CurrencyBridge::Wrap, native_idx, amount)
720            .expect("Wrap bridge encodes");
721        // V4_TAKE_COMPACT = 0x52 (1) + currency_idx (1) + recipient_idx (1) + amount_u96 (12) = 15 bytes
722        // WETH_DEPOSIT = 0x12 (1) + amount_u256 (32) = 33 bytes
723        assert_eq!(inner.len(), 15 + 33);
724        assert_eq!(inner[0], 0x52); // CMD_V4_TAKE_COMPACT
725        assert_eq!(inner[1], native_idx);
726        assert_eq!(inner[2], SENTINEL_SELF);
727        assert_eq!(inner[15], 0x12); // CMD_WETH_DEPOSIT
728    }
729
730    #[test]
731    fn currency_bridge_indices_wrap_takes_native_settles_weth() {
732        // Wrap = native out, WETH in: take native from PM, settle WETH after the swap.
733        let (take, settle) = CurrencyBridge::Wrap.bridge_indices(SENTINEL_WETH, SENTINEL_NATIVE);
734        assert_eq!(take, SENTINEL_NATIVE, "Wrap takes native out of the PM");
735        assert_eq!(
736            settle, SENTINEL_WETH,
737            "Wrap settles WETH (the swap consumed WETH)"
738        );
739    }
740
741    #[test]
742    fn currency_bridge_indices_unwrap_takes_weth_settles_native() {
743        // Unwrap = WETH out, native in: take WETH from PM, settle native after the swap.
744        let (take, settle) = CurrencyBridge::Unwrap.bridge_indices(SENTINEL_WETH, SENTINEL_NATIVE);
745        assert_eq!(take, SENTINEL_WETH, "Unwrap takes WETH out of the PM");
746        assert_eq!(
747            settle, SENTINEL_NATIVE,
748            "Unwrap settles native (the swap consumed native)"
749        );
750    }
751
752    #[test]
753    fn currency_bridge_indices_none_is_unused_but_well_defined() {
754        // None never reaches `bridge_indices` (caller guards `needs_bridge()`);
755        // the method still returns a deterministic placeholder for safety.
756        let (take, settle) = CurrencyBridge::None.bridge_indices(SENTINEL_WETH, SENTINEL_NATIVE);
757        assert_eq!((take, settle), (0, 0));
758    }
759
760    #[test]
761    fn emit_currency_bridge_unwrap_emits_take_plus_withdraw() {
762        let mut inner = Vec::new();
763        let weth_idx = SENTINEL_WETH;
764        let amount = 2_000_000_000_000_000_000u128;
765        emit_currency_bridge(&mut inner, CurrencyBridge::Unwrap, weth_idx, amount)
766            .expect("Unwrap bridge encodes");
767        // V4_TAKE_COMPACT (15) + WETH_WITHDRAW (33) = 48 bytes
768        assert_eq!(inner.len(), 15 + 33);
769        assert_eq!(inner[0], 0x52); // CMD_V4_TAKE_COMPACT
770        assert_eq!(inner[15], 0x13); // CMD_WETH_WITHDRAW
771    }
772
773    #[test]
774    #[expect(clippy::similar_names)] // canonical a/b/c + c0/c1 V4 currency-index names
775    fn three_hop_v2_v4_v3_feeds_clamped_consumed_input_as_v4_swap_in() {
776        // Proves the CL-hop clamp reaches the executor: with the V4 hop
777        // over-fed (consumed_inputs[1] < hop_outputs[0]), the encoded V4
778        // swap-in amount equals consumed_inputs[1], NOT hop_outputs[0].
779        // (Cannot use `v4_simulate_swap` here — the encoder only needs the
780        // amounts, which the engine clamp already set.)
781        let forward = 2_000_000_000u128;
782        let clamped = 1_999_999_999u128; // 1-wei CL clamp margin
783        let rust = encode_cmd_3_hop(
784            &PathInfo::new(vec![
785                HopInfo::V2(V2HopInfo {
786                    pool_address: address!("1111111111111111111111111111111111111111"),
787                    token0_address: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
788                    token1_address: address!("A0b86991c6218b36c1D19D4a2e9Eb0cE3606eB48"),
789                    fee: 30,
790                    zfo: true,
791                }),
792                HopInfo::V4(V4HopInfo {
793                    pool_manager_address: address!("000000000004444c5dc75cB358380D2e3dE08A90"),
794                    pool_id_hex:
795                        "0x1111111111111111111111111111111111111111111111111111111111111111"
796                            .to_string(),
797                    currency0_address: address!("A0b86991c6218b36c1D19D4a2e9Eb0cE3606eB48"),
798                    currency1_address: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
799                    fee: 500,
800                    tick_spacing: 10,
801                    hook_address: address!("0000000000000000000000000000000000000000"),
802                    zfo: true,
803                }),
804                HopInfo::V3(V3HopInfo {
805                    pool_address: address!("6666666666666666666666666666666666666666"),
806                    token0_address: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
807                    token1_address: address!("A0b86991c6218b36c1D19D4a2e9Eb0cE3606eB48"),
808                    fee: 3000,
809                    zfo: true,
810                }),
811            ]),
812            1_000_000_000_000_000_000u128,
813            &[forward, 2_001_000_000_000_000_000u128, 2_001_000_000u128],
814            // consumed_inputs = [opt_input, clamped V4 swap-in, V3 input]
815            &[1_000_000_000_000_000_000u128, clamped, 2_001_000_000u128],
816            address!("DeAd0000000000000000000000000000000000Be"),
817            address!("000000000004444c5dc75cB358380D2e3dE08A90"),
818            address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
819            EncodeOptions::default(),
820        )
821        .expect("V2-V4-V3 encodes");
822        // The V4 swap-in (u96 amount) is the 4th byte after the V4_SWAP_COMPACT
823        // opcode at the offset emitted inside the v4_unlock. Locate the opcode
824        // sequence (CMD_V4_SWAP_COMPACT) and read the following u96 amount.
825        // Simpler: re-derive the expected via the same encoder primitives as
826        // the goldens and assert equality on the full stream using clamped.
827        let mut at = AddressTable::with_sentinels(
828            Some(address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")),
829            Some(address!("DeAd0000000000000000000000000000000000Be")),
830            Some(address!("000000000004444c5dc75cB358380D2e3dE08A90")),
831        );
832        let pm_idx = at
833            .add(address!("000000000004444c5dc75cB358380D2e3dE08A90"))
834            .unwrap();
835        let forward_a_idx = at
836            .add(address!("A0b86991c6218b36c1D19D4a2e9Eb0cE3606eB48"))
837            .unwrap();
838        let forward_b_idx = at
839            .add(address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"))
840            .unwrap();
841        let executor_idx = SENTINEL_SELF;
842        let zero_idx = SENTINEL_NATIVE;
843        let v2a_idx = at
844            .add(address!("1111111111111111111111111111111111111111"))
845            .unwrap();
846        let v3c_idx = at
847            .add(address!("6666666666666666666666666666666666666666"))
848            .unwrap();
849        let c0_b_idx = at
850            .add(address!("A0b86991c6218b36c1D19D4a2e9Eb0cE3606eB48"))
851            .unwrap();
852        let c1_b_idx = at
853            .add(address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"))
854            .unwrap();
855        let mut v4_inner = Vec::new();
856        v4_inner.extend_from_slice(&encoders::enc_v4_sync(forward_a_idx));
857        v4_inner.extend_from_slice(&encoders::enc_v2_swap_calc(v2a_idx, true, pm_idx, 30));
858        v4_inner.extend_from_slice(&encoders::enc_v4_settle());
859        // V4 swap-in amount = the CL clamp = clamped, NOT forward.
860        v4_inner.extend_from_slice(
861            &encoders::enc_v4_swap_compact(c0_b_idx, c1_b_idx, 500, 10, zero_idx, true, clamped)
862                .unwrap(),
863        );
864        v4_inner.extend_from_slice(
865            // Exact-match: the V4 take carries consumed_inputs[2] (the v3c exit
866            // swap-in), NOT the solver's over-predictable out_b (path-73385).
867            &encoders::enc_v4_take_compact(forward_b_idx, v3c_idx, 2_001_000_000).unwrap(),
868        );
869        // The CL clamp caps the V4 swap-in below the settled V2 forward, leaving
870        // a residual on the settled currency (forward_a). Sweep it back so the
871        // unlock nets to zero (else CurrencyNotSettled at unlock exit).
872        v4_inner.extend_from_slice(&encoders::enc_v4_settle_delta(forward_a_idx));
873        let mut c_fwd = Vec::new();
874        c_fwd.extend_from_slice(
875            &encoders::enc_erc20_transfer(SENTINEL_WETH, v2a_idx, 1_000_000_000_000_000_000)
876                .unwrap(),
877        );
878        c_fwd.extend_from_slice(&encoders::enc_v4_unlock(&v4_inner).unwrap());
879        let commands = encoders::enc_v3_swap_compact(
880            v3c_idx,
881            true,
882            // exact-match: the v3c exit swap-in = consumed_inputs[2]
883            2_001_000_000,
884            executor_idx,
885            &c_fwd,
886        )
887        .unwrap();
888        let mut expected = encoders::enc_preamble(&at);
889        expected.extend_from_slice(&commands);
890        assert_eq!(
891            rust, expected,
892            "V4 swap-in must be the clamped consumed_inputs[1], not hop_outputs[0]"
893        );
894    }
895
896    /// The V4 exit take in `three_hop_v3_v4_v3` must use `consumed_inputs[2]`
897    /// (the byte-exact V4 output, path-73385 twin) — NOT the solver's raw
898    /// `hop_outputs[1]`, which can over-predict the V4 output by a few wei and
899    /// over-take the pool, stranding a residual delta that the trailing
900    /// V4_SETTLE_ALL repays via a failing `USDT.transfer(PM, …)` (0xfe halt).
901    #[test]
902    fn three_hop_v3_v4_v3_take_uses_consumed_inputs2_not_hop_outputs1() {
903        // Path-73385 numbers: solver predicted V4 output 85097884 (hop_outputs[1])
904        // but the pool's byte-exact twin output is 85097881 (consumed_inputs[2]).
905        let optimal_input = 44_421_383_036_608_956u128;
906        let v4_predicted = 85_097_884u128;
907        let v4_actual = 85_097_881u128;
908        let take_from = |consumed2| {
909            let rust = encode_cmd_3_hop(
910                &PathInfo::new(vec![
911                    HopInfo::V3(V3HopInfo {
912                        pool_address: address!("E0554a476A092703abdB3Ef35c80e0D76d32939F"),
913                        token0_address: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
914                        token1_address: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
915                        fee: 100,
916                        zfo: false,
917                    }),
918                    HopInfo::V4(V4HopInfo {
919                        pool_manager_address: address!("000000000004444c5dc75cB358380D2e3dE08A90"),
920                        pool_id_hex:
921                            "0x8aa4e11cbdf30eedc92100f4c8a31ff748e201d44712cc8c90d189edaa8e4e47"
922                                .to_string(),
923                        currency0_address: address!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"),
924                        currency1_address: address!("dAC17F958D2ee523a2206206994597C13D831ec7"),
925                        fee: 10,
926                        tick_spacing: 1,
927                        hook_address: address!("0000000000000000000000000000000000000000"),
928                        zfo: true,
929                    }),
930                    HopInfo::V3(V3HopInfo {
931                        pool_address: address!("c7bBeC68d12a0d1830360F8Ec58fA599bA1b0e9b"),
932                        token0_address: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
933                        token1_address: address!("dAC17F958D2ee523a2206206994597C13D831ec7"),
934                        fee: 100,
935                        zfo: false,
936                    }),
937                ]),
938                optimal_input,
939                &[85_060_245, v4_predicted, 44_421_879_564_949_974],
940                // consumed_inputs = [opt, V4 swap-in, exact V4 output]
941                &[optimal_input, 85_060_245, consumed2],
942                address!("DeAd0000000000000000000000000000000000Be"),
943                address!("000000000004444c5dc75cB358380D2e3dE08A90"),
944                address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
945                EncodeOptions::default(),
946            )
947            .expect("V3-V4-V3 encodes");
948            // Locate the single V4_TAKE_COMPACT (0x52) and read the 12-byte amount.
949            let found = rust
950                .windows(15)
951                .find(|w| w[0] == 0x52 && w[3..].iter().any(|b| *b != 0))
952                .map(|w| {
953                    let mut a = [0u8; 16];
954                    a[4..].copy_from_slice(&w[3..15]); // 12 bytes at offset 3
955                    u128::from_be_bytes(a)
956                });
957            found
958        };
959        // With the exact twin amount in consumed_inputs[2], the take = that amount.
960        assert_eq!(take_from(v4_actual), Some(v4_actual));
961        // And it is NOT the solver's over-predicted hop_outputs[1].
962        assert_eq!(take_from(v4_actual), Some(v4_actual));
963        assert_ne!(take_from(v4_actual), Some(v4_predicted));
964    }
965}
966
967// ── resolve_axes (ADR-029 D1) ────────────────────────────────
968
969#[test]
970fn resolve_axes_default_is_custody_no_bribe() {
971    let (funding, capture, bribe) = resolve_axes(EncodeOptions::default());
972    assert_eq!(funding, crate::grammar_ledger::FundingSource::InPathFlash);
973    assert_eq!(capture, crate::grammar_ledger::ProfitCapture::Custody);
974    assert_eq!(bribe, crate::grammar_ledger::Bribe::None);
975}
976
977#[test]
978fn resolve_axes_legacy_erc6909_bool_forces_erc6909_capture() {
979    // Backwards-compat: erc6909_profit: true wins over the capture field.
980    for capture in [
981        crate::grammar_ledger::ProfitCapture::Custody,
982        crate::grammar_ledger::ProfitCapture::Owner,
983        crate::grammar_ledger::ProfitCapture::Native,
984    ] {
985        let opts = EncodeOptions {
986            erc6909_profit: true,
987            use_v4_batch: false,
988            capture,
989            ..Default::default()
990        };
991        assert_eq!(
992            resolve_axes(opts).1,
993            crate::grammar_ledger::ProfitCapture::Erc6909,
994            "legacy erc6909_profit:true must force Erc6909 even with capture={capture:?}"
995        );
996    }
997}
998
999#[test]
1000fn resolve_axes_capture_field_honored_when_legacy_bool_false() {
1001    for capture in [
1002        crate::grammar_ledger::ProfitCapture::Custody,
1003        crate::grammar_ledger::ProfitCapture::Owner,
1004        crate::grammar_ledger::ProfitCapture::Native,
1005        crate::grammar_ledger::ProfitCapture::Erc6909,
1006    ] {
1007        let opts = EncodeOptions {
1008            erc6909_profit: false,
1009            use_v4_batch: false,
1010            capture,
1011            ..Default::default()
1012        };
1013        assert_eq!(resolve_axes(opts).1, capture);
1014    }
1015}
1016
1017#[test]
1018fn resolve_axes_bribe_passes_through() {
1019    let opts = EncodeOptions {
1020        bribe: crate::grammar_ledger::Bribe::Some {
1021            bips: 50,
1022            recipient_idx: 0,
1023        },
1024        ..Default::default()
1025    };
1026    assert_eq!(
1027        resolve_axes(opts).2,
1028        crate::grammar_ledger::Bribe::Some {
1029            bips: 50,
1030            recipient_idx: 0
1031        }
1032    );
1033}
1034
1035#[test]
1036fn resolve_axes_funding_passes_through() {
1037    for funding in [
1038        crate::grammar_ledger::FundingSource::SelfFund,
1039        crate::grammar_ledger::FundingSource::PmLedger,
1040        crate::grammar_ledger::FundingSource::ExternalLender,
1041        crate::grammar_ledger::FundingSource::Erc6909BurnToSettle,
1042    ] {
1043        let opts = EncodeOptions {
1044            funding,
1045            ..Default::default()
1046        };
1047        assert_eq!(resolve_axes(opts).0, funding);
1048    }
1049}
1050
1051// ── config_for_options axis→config mapping ───────────────────
1052
1053#[test]
1054#[expect(clippy::unwrap_used)] // test asserts config bits; unwrap is fine
1055fn config_for_options_default_is_check_mode_1() {
1056    // U3WVLL defect fix: default (Custody, no bribe) → check_mode=1 (WETH+ETH
1057    // profit assert active). The contract reads its own combined balance at
1058    // start+end and asserts combined_after >= combined_before. This is the
1059    // "profit assert active nearly always" protection the operator wants.
1060    let cfg = config_for_options(EncodeOptions::default(), U256::ZERO).unwrap();
1061    assert_eq!(
1062        cfg & U256::from(255u64),
1063        U256::from(1u64),
1064        "default → check_mode=1"
1065    );
1066    assert_eq!(
1067        (cfg >> 8) & U256::from(65535u64),
1068        U256::ZERO,
1069        "no bribe by default"
1070    );
1071}
1072
1073#[test]
1074#[expect(clippy::unwrap_used)] // test asserts config bits; unwrap is fine
1075fn config_for_options_capture_erc6909_sets_check_mode_2() {
1076    let opts = EncodeOptions {
1077        capture: crate::grammar_ledger::ProfitCapture::Erc6909,
1078        ..Default::default()
1079    };
1080    let cfg = config_for_options(opts, U256::ZERO).unwrap();
1081    assert_eq!(
1082        cfg & U256::from(255u64),
1083        U256::from(2u64),
1084        "Erc6909 → check_mode=2"
1085    );
1086}
1087
1088#[test]
1089#[expect(clippy::unwrap_used)] // test asserts config bits; unwrap is fine
1090fn config_for_options_capture_native_is_check_mode_1() {
1091    // Native capture also uses check_mode=1 (WETH+ETH combined assert;
1092    // the in-stream WETH_WITHDRAW leaves the profit as ETH, still counted in
1093    // the combined balance). The profit assert is active for Native capture too.
1094    let opts = EncodeOptions {
1095        capture: crate::grammar_ledger::ProfitCapture::Native,
1096        ..Default::default()
1097    };
1098    let cfg = config_for_options(opts, U256::ZERO).unwrap();
1099    assert_eq!(
1100        cfg & U256::from(255u64),
1101        U256::from(1u64),
1102        "Native → check_mode=1 (assert active)"
1103    );
1104}
1105
1106#[test]
1107#[expect(clippy::unwrap_used)] // test asserts config bits; unwrap is fine
1108fn config_for_options_legacy_erc6909_bool_forces_check_mode_2() {
1109    // Backwards-compat: the legacy `erc6909_profit: true` bool forces
1110    // Erc6909 (via resolve_axes precedence) → check_mode=2.
1111    let opts = EncodeOptions {
1112        erc6909_profit: true,
1113        capture: crate::grammar_ledger::ProfitCapture::Custody, // overridden
1114        ..Default::default()
1115    };
1116    let cfg = config_for_options(opts, U256::ZERO).unwrap();
1117    assert_eq!(
1118        cfg & U256::from(255u64),
1119        U256::from(2u64),
1120        "legacy bool forces check_mode=2"
1121    );
1122}
1123
1124#[test]
1125#[expect(clippy::unwrap_used)] // test asserts config bits; unwrap is fine
1126fn config_for_options_bribe_packs_bips_and_recipient() {
1127    let opts = EncodeOptions {
1128        bribe: crate::grammar_ledger::Bribe::Some {
1129            bips: 500,
1130            recipient_idx: 3,
1131        },
1132        ..Default::default()
1133    };
1134    let cfg = config_for_options(opts, U256::ZERO).unwrap();
1135    // bits 8-23: bribe_bips = 500
1136    assert_eq!((cfg >> 8) & U256::from(65535u64), U256::from(500u64));
1137    // bits 24-31: bribe_recipient_idx = 3
1138    assert_eq!((cfg >> 24) & U256::from(255u64), U256::from(3u64));
1139}
1140
1141#[test]
1142#[expect(clippy::unwrap_used)] // test asserts config bits; unwrap is fine
1143fn config_for_options_expected_value_is_ignored() {
1144    // expected_value is IGNORED (the contract reads its own combined
1145    // balance at start+end). The high bits are always 0 regardless of the
1146    // operator-supplied expected_value.
1147    let ev = U256::from(0xBEEFu64);
1148    let cfg = config_for_options(EncodeOptions::default(), ev).unwrap();
1149    assert_eq!(
1150        cfg >> 32,
1151        U256::ZERO,
1152        "expected_value ignored by the builder"
1153    );
1154}
1155
1156#[test]
1157#[expect(clippy::unwrap_used)] // test asserts config bits; unwrap is fine
1158fn config_for_options_combines_all_axes() {
1159    // Erc6909 check + 5% bribe to coinbase.
1160    let opts = EncodeOptions {
1161        capture: crate::grammar_ledger::ProfitCapture::Erc6909,
1162        bribe: crate::grammar_ledger::Bribe::Some {
1163            bips: 500,
1164            recipient_idx: 0,
1165        },
1166        ..Default::default()
1167    };
1168    let cfg = config_for_options(opts, U256::from(1_000_000u64)).unwrap();
1169    assert_eq!(cfg & U256::from(255u64), U256::from(2u64)); // check_mode=2
1170    assert_eq!((cfg >> 8) & U256::from(65535u64), U256::from(500u64)); // bips
1171    assert_eq!((cfg >> 24) & U256::from(255u64), U256::ZERO); // recipient=0 (coinbase)
1172    assert_eq!(cfg >> 32, U256::ZERO); // expected_value ignored
1173}
1174
1175#[test]
1176#[expect(clippy::unwrap_used)] // test asserts config bits; unwrap is fine
1177fn config_for_options_capture_sweep_to_address_sets_check_mode_3() {
1178    // follow-up (767TN5): ProfitCapture::SweepToAddress routes to
1179    // check_mode=3 (SWEEP) — the only way to defeat the profit assert.
1180    let opts = EncodeOptions {
1181        capture: crate::grammar_ledger::ProfitCapture::SweepToAddress,
1182        ..Default::default()
1183    };
1184    let cfg = config_for_options(opts, U256::ZERO).unwrap();
1185    assert_eq!(
1186        cfg & U256::from(255u64),
1187        U256::from(3u64),
1188        "SweepToAddress → check_mode=3 (SWEEP)"
1189    );
1190}