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}