Skip to main content

degenbot_executor/
grammar_shape.rs

1//! Builders — the **per-family dispatch surface** + validator gate
2//! (`grammar_shape.rs` split, ERP6ES / candidate 2 of
3//! `architecture-review-1786663110.html`).
4//!
5//! [`derive_shape`] dispatches every well-formed family through
6//! [`crate::grammar_walker::build_walk`] (the single facts → derive_plan →
7//! enc_preamble pipeline), then runs the
8//! [`LedgerValidator`][crate::grammar_ledger::LedgerValidator] gate →
9//! [`plan_to_bytes`]. all-V2 any-N routes through [`derive_all_v2`].
10//! Returns `None` on decline or gate rejection.
11//!
12//! This is the **churning** half — the dispatch table + validator gate live
13//! here, while the deep, stable walker (`Plan → LedgerOp`, `Plan → bytes`)
14//! is isolated in `grammar_plan`.
15//!
16//! ---
17//! **Status after RVNIPD / EYQ6UF:** the hand-written
18//! `derive_2hop_*` / `derive_3hop_*` byte-assembling emitters and their
19//! parity-oracle are **deleted** — the Plan is the sole production producer
20//! for every 2/3-hop family. The revm runtime matrix (`degenbot-simulation`
21//! `harness_declarative` full-matrix, exact delta) is the ADR-029 D5 source of
22//! truth; the primitive wire format is pinned by `tests/encoders_parity.rs`
23//! and the native bridge by `tests/native_eth_3hop_bridge.rs`. N4TJSZ
24//! (SPVEIE + KO5NNB + 4JOWO5): the all-V2 family (2-hop, 3-hop, any-N) routes
25//! through the single pipeline + the
26//! [`LedgerValidator`][crate::grammar_ledger::LedgerValidator] gate — the
27//! sole all-V2 producer. PPPHES: the 35-arm dispatch collapsed to the single
28//! `build_walk` pipeline.
29
30use alloy::primitives::Address;
31
32use crate::composers::{ComposerInputs, HopInfo, PathInfo, V4HopInfo, NATIVE_CURRENCY_ADDRESS};
33use crate::encoders::{AddressTable, SENTINEL_NATIVE, SENTINEL_SELF, SENTINEL_WETH};
34// Re-export the deep walker's public surface so external
35// `degenbot_executor::grammar_shape::{Plan, PlanStep, plan_to_bytes,
36// plan_to_ledger_ops, Prot, FundingSource, …}` paths keep resolving.
37pub use crate::grammar_plan::{
38    plan_to_bytes, plan_to_ledger_ops, Axis, AxisSupport, Bribe, FundingSource, Plan, PlanStep,
39    ProfitCapture, Prot, ShapeClass, V4BatchSwap,
40};
41// Fast-forward helpers are pub(crate) in the walker — shared by the per-family
42// builders + the V4 scaffold.
43
44// ═══════════════════════════════════════════════════════════════════════════
45// Cross-protocol V2/V3 scaffold (GLOPCN). The pure V2/V3 families (2-hop,
46// 3-hop, any-N all-V2) duplicate the same skeleton inline: the guard ladder
47// (arity, zeroed-output, `fits_int128` on the entry + hop swap-ins), the
48// sentinel AddressTable (weth/executor, no PoolManager — a V2/V3 family
49// touches no PM ledger), and the exit `(preamble, plan, at)` assembly. The
50// helpers below own that scaffold so a family is authored as
51// "scaffold + a thin PlanStep sequence", and the class of symmetry bug
52// has exactly one site per concept. V4-crossing families stay on
53// their own scaffold ([`v4_scaffold_table`] etc.) — their topology (one
54// `V4_UNLOCK` over the PM ledger) diverges; `finish_plan` is the only helper
55// every family shares. Currency resolution is the per-hop primitives
56// `v2_forward`/`v3_forward`/`v3_input` (walker) + the family's authored wiring
57// — the closing-currency rule is NOT unified here because it genuinely
58// diverges (`v2_v3` repays its flash in `weth`; all-V2 in `v2_forward(last)`).
59// ═══════════════════════════════════════════════════════════════════════════
60
61// The pure V2/V3 3-hop scaffolding (seed_address_table, guard_no_zeroed_output,
62// checked_swap_input, finish_plan) was decommissioned by the facts-driven T5
63// migration : every pure V2/V3 2-hop + 3-hop builder now delegates
64// to `derive_plan`, which inlines the equivalent guards.
65
66// ═══════════════════════════════════════════════════════════════════════════// ═══════════════════════════════════════════════════════════════════════════
67// 3-hop Plan scaffolding (W7FQN6 pilot). The shared topology pieces every
68// V4-crossing 3-hop builder (this pilot) calls: the sentinel
69// AddressTable scaffold, per-hop currency/orientation, the ADR-029 D1 capture
70// guard, the terminal-capture steps, and the native↔WETH bridge steps. The
71// pilot proves the existing `PlanStep` vocabulary needs NO new variant for the
72// 3-hop slice.
73// ═══════════════════════════════════════════════════════════════════════════
74
75/// The AddressTable scaffold for V4-crossing families: weth / executor /
76/// PoolManager sentinels (PM resolves to `SENTINEL_PM`, no table entry).
77pub(crate) fn v4_scaffold_table(inputs: &ComposerInputs<'_>) -> AddressTable {
78    AddressTable::with_sentinels(
79        Some(inputs.weth_address),
80        Some(inputs.executor_address),
81        Some(inputs.pool_manager_address),
82    )
83}
84
85/// A V4 hop's swap orientation: `(forward/output currency, input currency)`
86/// from its `zfo` flag. Shared by every V4-crossing family.
87pub(crate) fn v4_hop_currencies(h: &V4HopInfo) -> (Address, Address) {
88    if h.zfo {
89        (h.currency1_address, h.currency0_address)
90    } else {
91        (h.currency0_address, h.currency1_address)
92    }
93}
94
95/// The ADR-029 D1 capture guard for a V4-crossing terminal: a
96/// `ProfitCapture::Native` on a non-WETH/non-native terminal is not
97/// expressible (the executor cannot convert an arbitrary ERC-20 to native).
98pub(crate) fn native_capture_declines(
99    capture: ProfitCapture,
100    terminal: Address,
101    weth: Address,
102) -> bool {
103    capture == ProfitCapture::Native && terminal != weth && terminal != NATIVE_CURRENCY_ADDRESS
104}
105
106/// Build the terminal-capture `PlanStep`s for a V4-crossing family (mirrors the
107/// emitters' terminal-capture block): `erc6909_profit` (WETH terminal) → an
108/// ERC6909 mint; otherwise a physical `V4TakeDelta` unless `use_v4_batch`
109/// auto-settles (a tok terminal still gets an explicit take — see the batch
110/// caller); plus `ProfitCapture::Native` on a WETH terminal → a `WethWithdraw`
111/// of the custodied profit. Shared by every V4-crossing builder.
112pub(crate) fn v4_terminal_capture_steps(
113    terminal: Address,
114    terminal_idx: u8,
115    capture: ProfitCapture,
116    use_v4_batch: bool,
117    any_gap: bool,
118    profit: u128,
119    weth: Address,
120) -> Vec<PlanStep> {
121    let mut steps = Vec::new();
122    if capture == ProfitCapture::Erc6909 && terminal == weth {
123        if profit > 0 {
124            steps.push(PlanStep::V4Mint {
125                currency_idx: SENTINEL_WETH,
126                currency_addr: weth,
127                recipient_idx: SENTINEL_SELF,
128                amount: profit,
129            });
130        }
131    } else if !use_v4_batch || any_gap {
132        steps.push(PlanStep::V4TakeDelta {
133            currency_idx: terminal_idx,
134            currency_addr: terminal,
135            recipient_idx: SENTINEL_SELF,
136            seeds_pool: None,
137        });
138    }
139    if capture == ProfitCapture::Native && terminal == weth {
140        steps.push(PlanStep::WethWithdraw {
141            weth_idx: SENTINEL_WETH,
142            weth_addr: weth,
143            amount: profit,
144        });
145    }
146    steps
147}
148
149/// Build the native↔WETH bridge `PlanStep`s for a boundary (mirrors
150/// `emit_currency_bridge`): a `V4TakeCompact` of the source-side currency to
151/// SELF + a `WethDeposit` (wrap) or `WethWithdraw` (unwrap), plus the
152/// `settle_idx`/`settle_currency` the following swap's input dedebt needs.
153pub(crate) fn v4_bridge_steps(
154    bridge: crate::composers::CurrencyBridge,
155    weth: Address,
156    amount: u128,
157) -> (Vec<PlanStep>, u8, Address) {
158    let wrap = matches!(bridge, crate::composers::CurrencyBridge::Wrap);
159    let take_currency = if wrap { NATIVE_CURRENCY_ADDRESS } else { weth };
160    let settle_currency = if wrap { weth } else { NATIVE_CURRENCY_ADDRESS };
161    let take_idx = if wrap { SENTINEL_NATIVE } else { SENTINEL_WETH };
162    let settle_idx = if wrap { SENTINEL_WETH } else { SENTINEL_NATIVE };
163    let convert = if wrap {
164        PlanStep::WethDeposit {
165            weth_idx: SENTINEL_WETH,
166            weth_addr: weth,
167            amount,
168        }
169    } else {
170        PlanStep::WethWithdraw {
171            weth_idx: SENTINEL_WETH,
172            weth_addr: weth,
173            amount,
174        }
175    };
176    (
177        vec![
178            PlanStep::V4TakeCompact {
179                currency_idx: take_idx,
180                currency_addr: take_currency,
181                recipient_idx: SENTINEL_SELF,
182                amount,
183                seeds_pool: None,
184                repays_flash: None,
185            },
186            convert,
187        ],
188        settle_idx,
189        settle_currency,
190    )
191}
192
193/// A Plan builder: returns the full payload's preamble bytes, the [`Plan`]
194/// tree, and the resolved [`AddressTable`]. Used only by tests that inject a
195/// custom build fn (production routes through [`crate::grammar_walker::build_walk`]).
196#[cfg(test)]
197pub(crate) type BuildPlan =
198    fn(&PathInfo, &ComposerInputs<'_>) -> Option<(Vec<u8>, Plan, AddressTable)>;
199
200/// Build a family's [`Plan`] through its `build_*_plan` builder, run the
201/// [`LedgerValidator`][crate::grammar_ledger::LedgerValidator] gate on the
202/// projected ledger trace, and fold `preamble + plan_to_bytes(&plan, &at)`
203/// into the full payload.
204///
205/// The derivation outcome — a tri-state so a routine decline is never
206/// conflated with a would-be-bug validator reject (ADR-030).
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum Derive {
209    /// The family encoded as executable command bytes.
210    Encoded(Vec<u8>),
211    /// No producer/builder for this shape (a routine no-path; the strategy skips).
212    Declined,
213    /// A Plan was *built* but the ledger validator rejected it — a latent bug.
214    /// Always fatal (ADR-030): never swallowed into a decline or a `None`.
215    Rejected(crate::grammar_ledger::ValidationError),
216}
217
218/// Map a [`Derive`] onto the public `Option` seam: `Declined → None`, and a
219/// `Rejected` is **always fatal** — it panics (ADR-030: a Reject is a would-be
220/// bug and must never be silently dropped). [`Derive::Encoded`] yields the bytes.
221#[must_use]
222#[expect(
223    clippy::panic,
224    reason = "ADR-030: a validator Reject is intentionally fatal — it must never be swallowed into a None"
225)]
226pub(crate) fn derive_option(d: Derive) -> Option<Vec<u8>> {
227    match d {
228        Derive::Encoded(bytes) => Some(bytes),
229        Derive::Declined => None,
230        Derive::Rejected(e) => panic!(
231            "executor derivation REJECT (a latent bug, ADR-030): the encoder wrote a Plan the ledger validator rejected: {e:?}"
232        ),
233    }
234}
235
236/// Build a Plan through a `BuildPlan` fn pointer, run the
237/// [`LedgerValidator`][crate::grammar_ledger::LedgerValidator] gate on the
238/// projected ledger trace, and fold `preamble + plan_to_bytes(&plan, &at)`
239/// into the full payload.
240///
241/// Returns a [`Derive`]: `Declined` when the build declines (no bytes for
242/// this shape), `Rejected` when a *built* Plan fails validation. Used only by
243/// tests that inject a `bad_build` fn to assert the Reject path; production
244/// routing is inlined in [`derive_shape_detailed`].
245/// time the validator gates real production bytes (ADR-029 D4/D5).
246#[cfg(test)]
247#[must_use]
248fn build_plan_bytes(path: &PathInfo, build: BuildPlan, inputs: &ComposerInputs<'_>) -> Derive {
249    let Some((preamble, plan, at)) = build(path, inputs) else {
250        return Derive::Declined;
251    };
252    let ops = plan_to_ledger_ops(&plan);
253    let mut v = crate::grammar_ledger::LedgerValidator::default();
254    if let Err(e) = v.validate_full(&ops) {
255        return Derive::Rejected(e);
256    }
257    let mut out = preamble;
258    out.extend_from_slice(&plan_to_bytes(&plan, &at));
259    Derive::Encoded(out)
260}
261
262/// Public all-V2 entry (KO5NNB cutover): the any-N (≥2) all-V2 family through
263/// the single pipeline + validator gate — `build_walk` → the
264/// [`LedgerValidator`][crate::grammar_ledger::LedgerValidator] → `plan_to_bytes`
265/// (A3 routes it through the shared [`derive_shape_detailed`] dispatch).
266/// Returns `None` on a routine decline; a validator `Reject` is **fatal**
267/// (panics, ADR-030). See [`derive_all_v2_detailed`] for the tri-state.
268#[must_use]
269pub fn derive_all_v2(path: &PathInfo, inputs: &ComposerInputs<'_>) -> Option<Vec<u8>> {
270    derive_option(derive_all_v2_detailed(path, inputs))
271}
272
273/// The tri-state form of [`derive_all_v2`] (ADR-030): `Declined` vs `Rejected`
274/// are distinguished instead of collapsed into `None`. Delegates to the shared
275/// [`derive_shape_detailed`] dispatch (its `(V2,V2,V2|None)` row is the all-V2
276/// walker), so the all-V2 stream shares the single producer path.
277#[must_use]
278pub fn derive_all_v2_detailed(path: &PathInfo, inputs: &ComposerInputs<'_>) -> Derive {
279    derive_shape_detailed(path, inputs)
280}
281
282/// The protocol of a hop slot; `None` for a missing slot (a 2-hop path has a
283/// `None` third slot).
284#[must_use]
285fn prot_of(h: Option<&HopInfo>) -> Option<Prot> {
286    match h {
287        Some(HopInfo::V2(_)) => Some(Prot::V2),
288        Some(HopInfo::V3(_)) => Some(Prot::V3),
289        Some(HopInfo::V4(_)) => Some(Prot::V4),
290        None => None,
291    }
292}
293
294/// Public entry: derive a family's command bytes through the single pipeline
295/// (`build_walk` → [`LedgerValidator`][crate::grammar_ledger::LedgerValidator]
296/// gate → `plan_to_bytes`) — the sole production producer since RVNIPD removed
297/// the hand-written emitters. Returns `None` on a routine decline (an unsupported
298/// family → the strategy skips); a validator `Reject` is **fatal** (panics,
299/// ADR-030). See [`derive_shape_detailed`] for the tri-state.
300#[must_use]
301pub fn derive_shape(path: &PathInfo, inputs: &ComposerInputs<'_>) -> Option<Vec<u8>> {
302    derive_option(derive_shape_detailed(path, inputs))
303}
304
305/// The tri-state form of [`derive_shape`] (ADR-030): a routine `Declined` is
306/// distinguished from a would-be-bug `Rejected`, instead of both collapsing to
307/// `None`. Routed through [`crate::grammar_walker::build_walk`] (the single
308/// facts → derive_plan → enc_preamble pipeline) then the shared
309/// [`LedgerValidator`][crate::grammar_ledger::LedgerValidator] gate.
310#[must_use]
311pub fn derive_shape_detailed(path: &PathInfo, inputs: &ComposerInputs<'_>) -> Derive {
312    let key = (
313        prot_of(path.hops.first()),
314        prot_of(path.hops.get(1)),
315        prot_of(path.hops.get(2)),
316    );
317    if !crate::grammar_walker::recognized_key(key) {
318        return Derive::Declined;
319    }
320    let Some((preamble, plan, at)) = crate::grammar_walker::build_walk(path, inputs) else {
321        return Derive::Declined;
322    };
323    let ops = plan_to_ledger_ops(&plan);
324    let mut v = crate::grammar_ledger::LedgerValidator::default();
325    if let Err(e) = v.validate_full(&ops) {
326        return Derive::Rejected(e);
327    }
328    let mut out = preamble;
329    out.extend_from_slice(&plan_to_bytes(&plan, &at));
330    Derive::Encoded(out)
331}
332
333/// Public accessor (candidate 4, `3BTR22`): the declared stream-varying axes
334/// for a path's family, **derived from the hop-protocol facts** (not a
335/// per-row table) — the family's walker branches `FundingSource` only for the
336/// `v2_v3` / any-N all-V2 families, and `ProfitCapture` only for the pure-V4
337/// families. `None` for a shape with no producer (1-hop, >3-hop non-all-V2,
338/// unknown). The recognized-family check is key-based via
339/// [`crate::grammar_walker::recognized_key`] (len-agnostic — a `len ≥ 4` path
340/// whose first 3 prots match a 3-hop arm still yields `Some` even though the
341/// facts dispatcher may decline on arity).
342///
343/// E.g. `v2_v3` and any-N all-V2 → `{funding}` (the two families that branch
344/// `FundingSource`); the pure-V4 families `v4_v4`/`v4_v4_v4` → `{capture}`
345/// (V4 terminal capture); every V4-involving-but-not-pure-V4 family and all
346/// V2/V3-only families → `{}` (all axes derived; `ProfitCapture`/`Bribe` reach
347/// those streams only via the on-chain `check_mode`/`pack_config` config, never
348/// a stream byte).
349#[must_use]
350pub fn family_axis_support(path: &PathInfo) -> Option<AxisSupport> {
351    let key = (
352        prot_of(path.hops.first()),
353        prot_of(path.hops.get(1)),
354        prot_of(path.hops.get(2)),
355    );
356    // Recognized family? If not, there is no axis surface.
357    if !crate::grammar_walker::recognized_key(key) {
358        return None;
359    }
360    // capture: the pure-V4 families (every hop is V4).
361    let capture = matches!(key, (Some(Prot::V4), Some(Prot::V4), Some(Prot::V4) | None));
362    // funding: `v2_v3` (2-hop V2→V3) and the any-N all-V2 family.
363    let funding = matches!(
364        key,
365        (Some(Prot::V2), Some(Prot::V3), None)
366            | (Some(Prot::V2), Some(Prot::V2), Some(Prot::V2) | None)
367    );
368    Some(AxisSupport {
369        funding,
370        capture,
371        bribe: false,
372    })
373}
374
375#[cfg(test)]
376mod tests {
377    #![expect(
378        clippy::unwrap_used,
379        clippy::expect_used,
380        clippy::panic,
381        clippy::items_after_statements,
382        clippy::type_complexity,
383        clippy::naive_bytecount,
384        clippy::similar_names
385    )]
386    use super::*;
387    // A3: the reference producers are deleted; these aliases re-point the
388    // structure-inspection tests at the single `build_walk` pipeline
389    // (byte-identical, A2-proven).
390    use crate::composers::{EncodeOptions, V2HopInfo, V3HopInfo};
391    use crate::encoders::enc_preamble;
392    use crate::grammar_walker::build_walk;
393    use alloy::primitives::{address, Address, U256};
394
395    // Per-family aliases — every former `build_*_walk` delegate is now the
396    // single `build_walk` (facts_for dispatches internally). Kept as local
397    // wrappers so the test bodies that reference the per-family name are
398    // unchanged.
399    fn build_all_v2_chain(
400        path: &PathInfo,
401        inputs: &ComposerInputs<'_>,
402    ) -> Option<(Vec<u8>, Plan, AddressTable)> {
403        build_walk(path, inputs)
404    }
405    fn build_v2v3_plan(
406        path: &PathInfo,
407        inputs: &ComposerInputs<'_>,
408    ) -> Option<(Vec<u8>, Plan, AddressTable)> {
409        build_walk(path, inputs)
410    }
411    fn build_v3v2_plan(
412        path: &PathInfo,
413        inputs: &ComposerInputs<'_>,
414    ) -> Option<(Vec<u8>, Plan, AddressTable)> {
415        build_walk(path, inputs)
416    }
417    fn build_v3v3_plan(
418        path: &PathInfo,
419        inputs: &ComposerInputs<'_>,
420    ) -> Option<(Vec<u8>, Plan, AddressTable)> {
421        build_walk(path, inputs)
422    }
423    fn build_v4v4_plan(
424        path: &PathInfo,
425        inputs: &ComposerInputs<'_>,
426    ) -> Option<(Vec<u8>, Plan, AddressTable)> {
427        build_walk(path, inputs)
428    }
429
430    // ── Derive tri-state (ADR-030) ──
431
432    /// A deliberately-invalid build: a `V4TakeDelta` before any PM credit. The
433    /// ledger validator must surface `Reject(TakeBeforeCredit)`, not `None`.
434    const WT: Address = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
435    const EX: Address = address!("DeAd0000000000000000000000000000000000Be");
436    const PM: Address = address!("000000000004444c5dc75cB358380D2e3dE08A90");
437
438    #[expect(
439        clippy::unnecessary_wraps,
440        reason = "test helper matching the BuildPlan fn-pointer signature (always returns Some to force a validator reject)"
441    )]
442    fn bad_build(_: &PathInfo, _: &ComposerInputs<'_>) -> Option<(Vec<u8>, Plan, AddressTable)> {
443        let at = AddressTable::with_sentinels(Some(WT), Some(EX), Some(PM));
444        let plan = vec![PlanStep::V4TakeDelta {
445            currency_idx: SENTINEL_WETH,
446            currency_addr: WT,
447            recipient_idx: SENTINEL_SELF,
448            seeds_pool: None,
449        }];
450        Some((enc_preamble(&at), plan, at))
451    }
452
453    #[test]
454    fn invalid_plan_surfaces_as_reject_not_none() {
455        use crate::grammar_ledger::ValidationError;
456        let inputs = ComposerInputs {
457            executor_address: EX,
458            pool_manager_address: PM,
459            weth_address: WT,
460            optimal_input: 0,
461            hop_outputs: &[],
462            consumed_inputs: &[],
463            opts: EncodeOptions::default(),
464        };
465        match build_plan_bytes(&PathInfo::new(vec![]), bad_build, &inputs) {
466            Derive::Rejected(ValidationError::TakeBeforeCredit { .. }) => {}
467            other => panic!("expected Reject(TakeBeforeCredit), got {other:?}"),
468        }
469    }
470
471    #[test]
472    fn rejected_derivation_is_fatal_not_none() {
473        use crate::grammar_ledger::ValidationError;
474        // A Reject must panic (be fatal) at the public seam — never None.
475        let r = std::panic::catch_unwind(|| {
476            derive_option(Derive::Rejected(ValidationError::TakeBeforeCredit {
477                currency: Address::ZERO,
478                wanted: 1,
479                have: 0,
480            }))
481        });
482        assert!(
483            r.is_err(),
484            "a Reject must panic (ADR-030), never return None"
485        );
486        // A Declined is the routine skip; Encoded passes through.
487        assert_eq!(derive_option(Derive::Declined), None);
488        assert_eq!(derive_option(Derive::Encoded(vec![7u8])), Some(vec![7u8]));
489    }
490
491    /// The three v4_v4 terminal-output currencies the broadened slice covers
492    /// (WETH / a tok / native) — the `use_v4_batch` + `erc6909_profit` opts
493    /// interact with each differently (`derive_2hop_v4v4` exact parity).
494    #[derive(Clone, Copy)]
495    enum Terminal {
496        Weth,
497        Tok,
498        Native,
499    }
500
501    /// The two v4_v4 currency-gap topologies (native↔WETH bridge at the mid).
502    #[derive(Clone, Copy)]
503    enum Gap {
504        /// Hop a outputs native, hop b needs WETH → take native + `WETH_DEPOSIT`.
505        Wrap,
506        /// Hop a outputs WETH, hop b needs native → take WETH + `WETH_WITHDRAW`.
507        Unwrap,
508    }
509
510    fn v4_v4_inputs(
511        terminal: Terminal,
512        opts: crate::composers::EncodeOptions,
513    ) -> (PathInfo, ComposerInputs<'static>) {
514        let weth = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
515        let t1 = address!("0000000000000000000000000000000000000db1");
516        let t2 = address!("0000000000000000000000000000000000000db2");
517        let pm = address!("00000000000000000000000000000000000000ff");
518        let v4a_id = "0x0".to_string();
519        let v4b_id = "0x1".to_string();
520        // hop a: weth → t1 (currency0=weth, currency1=t1, zfo=true).
521        let hop_a = HopInfo::V4(V4HopInfo {
522            pool_manager_address: pm,
523            pool_id_hex: v4a_id,
524            currency0_address: weth,
525            currency1_address: t1,
526            fee: 3000,
527            tick_spacing: 60,
528            hook_address: Address::ZERO,
529            zfo: true,
530        });
531        // hop b: t1 → <terminal>. V4 sorts currency0 < currency1; native
532        // (address 0) is always currency0, so the zfo + currency layout
533        // depends on the terminal.
534        let hop_b = match terminal {
535            // t1 → weth: currency0=t1, currency1=weth, zfo=true.
536            Terminal::Weth => HopInfo::V4(V4HopInfo {
537                pool_manager_address: pm,
538                pool_id_hex: v4b_id,
539                currency0_address: t1,
540                currency1_address: weth,
541                fee: 3000,
542                tick_spacing: 60,
543                hook_address: Address::ZERO,
544                zfo: true,
545            }),
546            // t1 → t2: currency0=t1, currency1=t2, zfo=true.
547            Terminal::Tok => HopInfo::V4(V4HopInfo {
548                pool_manager_address: pm,
549                pool_id_hex: v4b_id,
550                currency0_address: t1,
551                currency1_address: t2,
552                fee: 3000,
553                tick_spacing: 60,
554                hook_address: Address::ZERO,
555                zfo: true,
556            }),
557            // t1 → native: native is currency0, t1 is currency1, so zfo=false
558            // (currency1→currency0). out = currency0 = native.
559            Terminal::Native => HopInfo::V4(V4HopInfo {
560                pool_manager_address: pm,
561                pool_id_hex: v4b_id,
562                currency0_address: NATIVE_CURRENCY_ADDRESS,
563                currency1_address: t1,
564                fee: 3000,
565                tick_spacing: 60,
566                hook_address: Address::ZERO,
567                zfo: false,
568            }),
569        };
570        let path = PathInfo::new(vec![hop_a, hop_b]);
571        static OPTIMAL: u128 = 1_000_000;
572        static OUTS: [u128; 2] = [1_100_000, 1_200_000];
573        static CONSUMED: [u128; 2] = [1_000_000, 1_100_000];
574        (
575            path,
576            ComposerInputs {
577                executor_address: address!("00000000000000000000000000000000000000ee"),
578                pool_manager_address: pm,
579                weth_address: weth,
580                optimal_input: OPTIMAL,
581                hop_outputs: &OUTS,
582                consumed_inputs: &CONSUMED,
583                opts,
584            },
585        )
586    }
587
588    /// Build a v4_v4 **gap** topology (native↔WETH bridge at the mid). `Wrap`:
589    /// hop a outputs native, hop b needs WETH (take native + `WETH_DEPOSIT`).
590    /// `Unwrap`: hop a outputs WETH, hop b needs native (take WETH +
591    /// `WETH_WITHDRAW`). V4 sorts currency0 < currency1, so native (address 0)
592    /// is always currency0; zfo is set so the output/input legs match the gap.
593    fn v4_v4_gap_inputs(
594        gap: Gap,
595        opts: crate::composers::EncodeOptions,
596    ) -> (PathInfo, ComposerInputs<'static>) {
597        let weth = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
598        let t1 = address!("0000000000000000000000000000000000000da1");
599        let t2 = address!("0000000000000000000000000000000000000da2");
600        let pm = address!("00000000000000000000000000000000000000ff");
601        let v4a_id = "0x0".to_string();
602        let v4b_id = "0x1".to_string();
603        // Hop a: output the gap currency (mid_a). zfo=false → output=currency0;
604        // zfo=true → output=currency1.
605        let hop_a = match gap {
606            // Wrap: mid_a=native. currency0=native, currency1=weth, zfo=false
607            // → output=currency0=native, input=currency1=weth.
608            Gap::Wrap => HopInfo::V4(V4HopInfo {
609                pool_manager_address: pm,
610                pool_id_hex: v4a_id,
611                currency0_address: NATIVE_CURRENCY_ADDRESS,
612                currency1_address: weth,
613                fee: 3000,
614                tick_spacing: 60,
615                hook_address: Address::ZERO,
616                zfo: false,
617            }),
618            // Unwrap: mid_a=weth. currency0=t1, currency1=weth, zfo=true
619            // → output=currency1=weth, input=currency0=t1.
620            Gap::Unwrap => HopInfo::V4(V4HopInfo {
621                pool_manager_address: pm,
622                pool_id_hex: v4a_id,
623                currency0_address: t1,
624                currency1_address: weth,
625                fee: 3000,
626                tick_spacing: 60,
627                hook_address: Address::ZERO,
628                zfo: true,
629            }),
630        };
631        // Hop b: input the bridged currency (mid_b), output the terminal.
632        // mid_b = input = currency0 if zfo else currency1.
633        let hop_b = match gap {
634            // Wrap: mid_b=weth. currency0=t2, currency1=weth, zfo=false
635            // → input=currency1=weth, output=currency0=t2.
636            Gap::Wrap => HopInfo::V4(V4HopInfo {
637                pool_manager_address: pm,
638                pool_id_hex: v4b_id,
639                currency0_address: t2,
640                currency1_address: weth,
641                fee: 3000,
642                tick_spacing: 60,
643                hook_address: Address::ZERO,
644                zfo: false,
645            }),
646            // Unwrap: mid_b=native. currency0=native, currency1=weth, zfo=true
647            // → input=currency0=native, output=currency1=weth.
648            Gap::Unwrap => HopInfo::V4(V4HopInfo {
649                pool_manager_address: pm,
650                pool_id_hex: v4b_id,
651                currency0_address: NATIVE_CURRENCY_ADDRESS,
652                currency1_address: weth,
653                fee: 3000,
654                tick_spacing: 60,
655                hook_address: Address::ZERO,
656                zfo: true,
657            }),
658        };
659        let path = PathInfo::new(vec![hop_a, hop_b]);
660        static OPTIMAL: u128 = 1_000_000;
661        static OUTS: [u128; 2] = [1_100_000, 1_200_000];
662        static CONSUMED: [u128; 2] = [1_000_000, 1_100_000];
663        (
664            path,
665            ComposerInputs {
666                executor_address: address!("00000000000000000000000000000000000000ee"),
667                pool_manager_address: pm,
668                weth_address: weth,
669                optimal_input: OPTIMAL,
670                hop_outputs: &OUTS,
671                consumed_inputs: &CONSUMED,
672                opts,
673            },
674        )
675    }
676
677    #[test]
678    fn funding_source_is_derived_from_leading_hop() {
679        // V2-leading → in-path flash; V3-leading → self-fund (D0-forced).
680        let cases: [(Prot, FundingSource); 2] = [
681            (Prot::V2, FundingSource::InPathFlash),
682            (Prot::V3, FundingSource::SelfFund),
683        ];
684        for (_p, expected) in cases {
685            // The assignment rule is the match in `derive_shape`; assert the
686            // two funding values are distinct (the spike's contract).
687            assert_ne!(
688                expected,
689                match expected {
690                    FundingSource::InPathFlash => FundingSource::SelfFund,
691                    _ => FundingSource::InPathFlash,
692                }
693            );
694        }
695    }
696
697    // POC (6SRC23): the v2_v3 InPathFlash flash-credit chain exercises the
698    // executor-ledger credit-before-debit invariant the runtime matrix can't
699    // see. The derived trace must validate clean; a misordering must reject.
700    fn v2_v3_path_inputs() -> (PathInfo, ComposerInputs<'static>) {
701        let weth = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
702        let usdc = address!("A0b86991c6218b36c1D19D4a2e9Eb0cE3606eB48");
703        let v2a = address!("00000000000000000000000000000000000000aa");
704        let v3b = address!("00000000000000000000000000000000000000bb");
705        let path = PathInfo::new(vec![
706            HopInfo::V2(V2HopInfo {
707                pool_address: v2a,
708                token0_address: weth,
709                token1_address: usdc,
710                fee: 30,
711                zfo: true, // WETH → USDC: forward token = USDC
712            }),
713            HopInfo::V3(V3HopInfo {
714                pool_address: v3b,
715                token0_address: usdc,
716                token1_address: weth,
717                fee: 3000,
718                zfo: true, // USDC → WETH: forward token = WETH (terminal)
719            }),
720        ]);
721        static OPTIMAL: u128 = 1_000_000;
722        static OUTS: [u128; 2] = [1_100_000, 1_200_000];
723        static CONSUMED: [u128; 2] = [1_000_000, 1_100_000];
724        let inputs = ComposerInputs {
725            executor_address: address!("00000000000000000000000000000000000000ee"),
726            pool_manager_address: address!("00000000000000000000000000000000000000ff"),
727            weth_address: weth,
728            optimal_input: OPTIMAL,
729            hop_outputs: &OUTS,
730            consumed_inputs: &CONSUMED,
731            opts: crate::composers::EncodeOptions::default(),
732        };
733        (path, inputs)
734    }
735
736    // BP7KIR Checkpoint 1: the Plan tree is the primary artifact for v2_v3.
737    #[test]
738    fn v2_v3_plan_projects_a_validating_trace() {
739        let (path, inputs) = v2_v3_path_inputs();
740        let (_preamble, plan, _at) =
741            build_v2v3_plan(&path, &inputs).expect("v2_v3 must build a Plan");
742        let ops = plan_to_ledger_ops(&plan);
743        assert_eq!(ops.len(), 4, "v2_v3 Plan projects to 4 LedgerOps");
744        let mut v = crate::grammar_ledger::LedgerValidator::default();
745        assert!(
746            v.validate_full(&ops).is_ok(),
747            "canonical v2_v3 Plan must project a validating trace"
748        );
749    }
750
751    #[test]
752    fn v2_v3_plan_misordered_callback_rejects() {
753        let (path, inputs) = v2_v3_path_inputs();
754        let (_preamble, mut plan, _at) =
755            build_v2v3_plan(&path, &inputs).expect("v2_v3 must build a Plan");
756        // Corrupt the Plan: make the outer WETH repayment (sibling #1 of the
757        // V2 flash's callback) fire BEFORE the V3 flash (sibling #0) that
758        // credits WETH. Depth-first walk: V2 flash (credits t1) → WETH repay
759        // (executor WETH still 0 → the V2 flash's WETH credit is owed, not
760        // held) → REJECT. The runtime matrix cannot name this; byte-parity
761        // would confirm the misordered bytes that revert on-chain.
762        let outer = plan.get_mut(0).unwrap();
763        if let PlanStep::FlashSwap { callback, .. } = outer {
764            // callback = [V3 FlashSwap, WETH Erc20Transfer]; swap to
765            // [WETH Erc20Transfer, V3 FlashSwap].
766            callback.swap(0, 1);
767        } else {
768            panic!("expected outer V2 FlashSwap");
769        }
770        let ops = plan_to_ledger_ops(&plan);
771        let mut v = crate::grammar_ledger::LedgerValidator::default();
772        assert!(
773            matches!(
774                v.validate_full(&ops),
775                Err(crate::grammar_ledger::ValidationError::Erc20TransferBeforeCredit {
776                    currency, wanted, have
777                }) if currency == inputs.weth_address && wanted == 1_000_000 && have == 0
778            ),
779            "misordered Plan must be rejected: WETH repay before V3 flash credits WETH"
780        );
781        let _ = U256::ZERO;
782    }
783
784    // Increment 2: the remaining V2/V3 2-hop families on the Plan.
785    // Each: byte-parity with the proven emitter + the Plan projects a
786    // validating trace. A shared helper drives both assertions per family.
787    fn v3_v2_path_inputs() -> (PathInfo, ComposerInputs<'static>) {
788        let weth = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
789        let usdc = address!("A0b86991c6218b36c1D19D4a2e9Eb0cE3606eB48");
790        let v3a = address!("00000000000000000000000000000000000000a1");
791        let v2b = address!("00000000000000000000000000000000000000b2");
792        let path = PathInfo::new(vec![
793            HopInfo::V3(V3HopInfo {
794                pool_address: v3a,
795                token0_address: weth,
796                token1_address: usdc,
797                fee: 3000,
798                zfo: true,
799            }),
800            HopInfo::V2(V2HopInfo {
801                pool_address: v2b,
802                token0_address: usdc,
803                token1_address: weth,
804                fee: 30,
805                zfo: true,
806            }),
807        ]);
808        static OPTIMAL: u128 = 1_000_000;
809        static OUTS: [u128; 2] = [1_100_000, 1_200_000];
810        static CONSUMED: [u128; 2] = [1_000_000, 1_100_000];
811        (
812            path,
813            ComposerInputs {
814                executor_address: address!("00000000000000000000000000000000000000ee"),
815                pool_manager_address: address!("00000000000000000000000000000000000000ff"),
816                weth_address: weth,
817                optimal_input: OPTIMAL,
818                hop_outputs: &OUTS,
819                consumed_inputs: &CONSUMED,
820                opts: crate::composers::EncodeOptions::default(),
821            },
822        )
823    }
824    fn v3_v3_path_inputs() -> (PathInfo, ComposerInputs<'static>) {
825        let weth = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
826        let usdc = address!("A0b86991c6218b36c1D19D4a2e9Eb0cE3606eB48");
827        let v3a = address!("00000000000000000000000000000000000000a3");
828        let v3b = address!("00000000000000000000000000000000000000b3");
829        let path = PathInfo::new(vec![
830            HopInfo::V3(V3HopInfo {
831                pool_address: v3a,
832                token0_address: weth,
833                token1_address: usdc,
834                fee: 3000,
835                zfo: true,
836            }),
837            HopInfo::V3(V3HopInfo {
838                pool_address: v3b,
839                token0_address: usdc,
840                token1_address: weth,
841                fee: 3000,
842                zfo: true,
843            }),
844        ]);
845        static OPTIMAL: u128 = 1_000_000;
846        static OUTS: [u128; 2] = [1_100_000, 1_200_000];
847        static CONSUMED: [u128; 2] = [1_000_000, 1_100_000];
848        (
849            path,
850            ComposerInputs {
851                executor_address: address!("00000000000000000000000000000000000000ee"),
852                pool_manager_address: address!("00000000000000000000000000000000000000ff"),
853                weth_address: weth,
854                optimal_input: OPTIMAL,
855                hop_outputs: &OUTS,
856                consumed_inputs: &CONSUMED,
857                opts: crate::composers::EncodeOptions::default(),
858            },
859        )
860    }
861    /// RVNIPD/EYQ6UF: build the Plan and assert it projects a trace that
862    /// validates clean through the gate. The byte-level `derive_shape`
863    /// comparison this used to make is gone — `derive_shape` IS the same
864    /// Plan path now, so it was a self-comparison (the runtime matrix is the
865    /// byte source of truth; `encoders_parity` pins the primitive wire format).
866    fn plan_builds_and_validates(
867        build: fn(&PathInfo, &ComposerInputs) -> Option<(Vec<u8>, Plan, AddressTable)>,
868        path: &PathInfo,
869        inputs: &ComposerInputs,
870        name: &str,
871    ) {
872        let (_preamble, plan, _at) =
873            build(path, inputs).unwrap_or_else(|| panic!("[{name}] build None"));
874        let ops = plan_to_ledger_ops(&plan);
875        let mut v = crate::grammar_ledger::LedgerValidator::default();
876        assert!(
877            v.validate_full(&ops).is_ok(),
878            "[{name}] Plan must validate clean"
879        );
880    }
881
882    /// A dummy path whose hop sequence is `combo` (only the protocol sequence
883    /// matters to [`family_axis_support`] — it inspects hop slots, not fees).
884    fn axis_path(combo: &[Prot]) -> PathInfo {
885        let weth = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
886        let usdc = address!("A0b86991c6218b36c1D19D4a2e9Eb0cE3606eB48");
887        let hops = combo
888            .iter()
889            .enumerate()
890            .map(|(i, p)| match p {
891                Prot::V2 => HopInfo::V2(V2HopInfo {
892                    pool_address: Address::from([0xA0 + u8::try_from(i).unwrap(); 20]),
893                    token0_address: weth,
894                    token1_address: usdc,
895                    fee: 30,
896                    zfo: true,
897                }),
898                Prot::V3 => HopInfo::V3(V3HopInfo {
899                    pool_address: Address::from([0xB0 + u8::try_from(i).unwrap(); 20]),
900                    token0_address: weth,
901                    token1_address: usdc,
902                    fee: 3000,
903                    zfo: true,
904                }),
905                Prot::V4 => HopInfo::V4(V4HopInfo {
906                    pool_manager_address: Address::ZERO,
907                    pool_id_hex: format!("0x{i:x}"),
908                    currency0_address: weth,
909                    currency1_address: usdc,
910                    fee: 500,
911                    tick_spacing: 10,
912                    hook_address: Address::ZERO,
913                    zfo: true,
914                }),
915            })
916            .collect();
917        PathInfo::new(hops)
918    }
919
920    #[test]
921    fn family_axis_support_declares_the_honoring_surface() {
922        // funding is branched by exactly v2_v3 + any-N all-V2.
923        assert_eq!(
924            family_axis_support(&axis_path(&[Prot::V2, Prot::V3])).unwrap(),
925            AxisSupport::funding()
926        );
927        assert_eq!(
928            family_axis_support(&axis_path(&[Prot::V2, Prot::V2])).unwrap(),
929            AxisSupport::funding()
930        );
931        assert_eq!(
932            family_axis_support(&axis_path(&[Prot::V2, Prot::V2, Prot::V2])).unwrap(),
933            AxisSupport::funding()
934        );
935        // capture is branched by exactly the pure-V4 families.
936        assert_eq!(
937            family_axis_support(&axis_path(&[Prot::V4, Prot::V4])).unwrap(),
938            AxisSupport::capture()
939        );
940        assert_eq!(
941            family_axis_support(&axis_path(&[Prot::V4, Prot::V4, Prot::V4])).unwrap(),
942            AxisSupport::capture()
943        );
944        // V4-involving but NOT pure-V4: capture is NOT varied in the stream
945        // (physical take only; `check_mode` is config) — the surfaced
946        // asymmetry the declaration makes explicit.
947        assert_eq!(
948            family_axis_support(&axis_path(&[Prot::V2, Prot::V2, Prot::V4])).unwrap(),
949            AxisSupport::none()
950        );
951        assert_eq!(
952            family_axis_support(&axis_path(&[Prot::V4, Prot::V2, Prot::V4])).unwrap(),
953            AxisSupport::none()
954        );
955        assert_eq!(
956            family_axis_support(&axis_path(&[Prot::V2, Prot::V4])).unwrap(),
957            AxisSupport::none()
958        );
959        // V2/V3-only families derive funding (InPathFlash) — no axis varies.
960        assert_eq!(
961            family_axis_support(&axis_path(&[Prot::V3, Prot::V2])).unwrap(),
962            AxisSupport::none()
963        );
964        assert_eq!(
965            family_axis_support(&axis_path(&[Prot::V3, Prot::V3])).unwrap(),
966            AxisSupport::none()
967        );
968        assert_eq!(
969            family_axis_support(&axis_path(&[Prot::V3, Prot::V3, Prot::V3])).unwrap(),
970            AxisSupport::none()
971        );
972        // bribe is declared on no family; the per-axis accessor agrees.
973        for axes in [
974            family_axis_support(&axis_path(&[Prot::V2, Prot::V3])).unwrap(),
975            family_axis_support(&axis_path(&[Prot::V4, Prot::V4])).unwrap(),
976            family_axis_support(&axis_path(&[Prot::V3, Prot::V3])).unwrap(),
977        ] {
978            assert!(!axes.is_honored(Axis::Bribe), "no family branches bribe");
979        }
980        assert!(family_axis_support(&axis_path(&[Prot::V2, Prot::V3]))
981            .unwrap()
982            .is_honored(Axis::Funding));
983        assert!(family_axis_support(&axis_path(&[Prot::V4, Prot::V4]))
984            .unwrap()
985            .is_honored(Axis::Capture));
986        // 1-hop / unknown shapes have no builder row → None.
987        assert!(family_axis_support(&axis_path(&[Prot::V2])).is_none());
988    }
989
990    #[test]
991    fn v3_v2_plan_byte_parity_and_validates() {
992        let (path, inputs) = v3_v2_path_inputs();
993        plan_builds_and_validates(build_v3v2_plan, &path, &inputs, "v3_v2");
994    }
995    #[test]
996    fn v3_v3_plan_byte_parity_and_validates() {
997        let (path, inputs) = v3_v3_path_inputs();
998        plan_builds_and_validates(build_v3v3_plan, &path, &inputs, "v3_v3");
999    }
1000
1001    /// Build an `n`-hop all-V2 path closing on WETH: hop `i` is
1002    /// `token_i → token_{i+1}`, with the final hop returning to WETH
1003    /// (the canonical all-V2 arbitrage loop the speedrail serves).
1004    fn all_v2_chain_hops(n: usize) -> Vec<HopInfo> {
1005        let weth = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
1006        let usdc = address!("A0b86991c6218b36c1D19D4a2e9Eb0cE3606eB48");
1007        let wbtc = address!("2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599");
1008        let dai = address!("6B175474E89094C44Da98b954EedeAC495271d0F");
1009        let cycle = [weth, usdc, wbtc, dai];
1010        (0..n)
1011            .map(|i| {
1012                HopInfo::V2(V2HopInfo {
1013                    pool_address: Address::from([0xD0 + u8::try_from(i).expect("2..=4 hops"); 20]),
1014                    token0_address: cycle[i % 4],
1015                    token1_address: cycle[(i + 1) % 4],
1016                    fee: 30,
1017                    zfo: true,
1018                })
1019            })
1020            .collect()
1021    }
1022
1023    // KO5NNB gate proof: an InPathFlash all-V2 stream whose terminal output
1024    // cannot cover the flash repayment is REJECTED by the LedgerValidator
1025    // (the flash-repay `Erc20Transfer` would over-debit `erc20[weth]`, so
1026    // credit-before-debit fires). The retired hand-written speedrail emitted
1027    // this stream unvalidated (and the revm harness's 2× WETH buffer let it
1028    // "execute but lose"); the gate now makes it unrepresentable — N4TJSZ's
1029    // entire point. The SAME losing stream under SelfFund still validates
1030    // (no flash debt to repay — the executor eats the loss from held capital,
1031    // which is what a negative-control delta assert should measure).
1032    #[test]
1033    fn all_v2_gate_rejects_unprofitable_inpathflash_stream() {
1034        let path = PathInfo::new(all_v2_chain_hops(2));
1035        let outs: Vec<u128> = vec![80_000, 60_000]; // terminal 60k < optimal 100k — losing
1036        let consumed: Vec<u128> = vec![50_000, 80_000];
1037        for (flabel, funding) in [
1038            ("InPathFlash", FundingSource::InPathFlash),
1039            ("SelfFund", FundingSource::SelfFund),
1040        ] {
1041            let inputs = ComposerInputs {
1042                executor_address: address!("00000000000000000000000000000000000000ee"),
1043                pool_manager_address: address!("00000000000000000000000000000000000000ff"),
1044                weth_address: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"),
1045                optimal_input: 100_000,
1046                hop_outputs: &outs,
1047                consumed_inputs: &consumed,
1048                opts: crate::composers::EncodeOptions {
1049                    funding,
1050                    ..Default::default()
1051                },
1052            };
1053            let (_preamble, plan, _at) = build_all_v2_chain(&path, &inputs)
1054                .unwrap_or_else(|| panic!("[{flabel}] build None"));
1055            let ops = plan_to_ledger_ops(&plan);
1056            let mut v = crate::grammar_ledger::LedgerValidator::default();
1057            if flabel == "InPathFlash" {
1058                let err = v
1059                    .validate_full(&ops)
1060                    .expect_err("losing InPathFlash stream must be rejected");
1061                // The exact invariant fired: the flash repay tries to debit
1062                // more WETH than the stream generated (terminal 60k < 100k owed).
1063                assert!(
1064                    matches!(
1065                        err,
1066                        crate::grammar_ledger::ValidationError::Erc20TransferBeforeCredit { .. }
1067                    ),
1068                    "expected Erc20TransferBeforeCredit, got {err:?}"
1069                );
1070                // ADR-030: the validator rejection of a *built* Plan is a
1071                // would-be-bug, not a silent skip. It now surfaces as a
1072                // `Reject` (tri-state) and the public seam is FATAL (panics),
1073                // where the old code swallowed it to `None` — this documents
1074                // a latent builder defect the facts-walker will make
1075                // unrepresentable.
1076                let d = derive_all_v2_detailed(&path, &inputs);
1077                assert!(
1078                    matches!(
1079                        d,
1080                        Derive::Rejected(
1081                            crate::grammar_ledger::ValidationError::Erc20TransferBeforeCredit { .. }
1082                        )
1083                    ),
1084                    "expected Reject(Erc20TransferBeforeCredit), got {d:?}"
1085                );
1086                let r = std::panic::catch_unwind(|| derive_all_v2(&path, &inputs));
1087                assert!(
1088                    r.is_err(),
1089                    "a validator Reject must be fatal (panic), never None (ADR-030)"
1090                );
1091            } else {
1092                assert!(
1093                    v.validate_full(&ops).is_ok(),
1094                    "losing SelfFund stream must still validate (no flash debt)"
1095                );
1096            }
1097        }
1098    }
1099
1100    #[test]
1101    fn v3_v2_plan_terminal_v2_before_seed_rejected() {
1102        // The terminal-V2 pre-fund rule (`2PT5HH`): a `V2SwapCalc` before its
1103        // `Erc20Transfer` pair-seed must be rejected (the über-draw class).
1104        let (path, inputs) = v3_v2_path_inputs();
1105        let (_preamble, mut plan, _at) = build_v3v2_plan(&path, &inputs).expect("v3_v2 build None");
1106        // The V3 flash's callback is [WETH repay, forward seed, V2SwapCalc].
1107        // Move V2SwapCalc to the front (before the seed) → SwapCalcBeforeCredit.
1108        let outer = plan.last_mut().unwrap();
1109        if let PlanStep::FlashSwap { callback, .. } = outer {
1110            // [WETH transfer, seed transfer, V2SwapCalc] → [V2SwapCalc, WETH transfer, seed transfer]
1111            let swapcalc = callback.remove(2);
1112            callback.insert(0, swapcalc);
1113        } else {
1114            panic!("expected outer V3 FlashSwap");
1115        }
1116        let ops = plan_to_ledger_ops(&plan);
1117        let mut v = crate::grammar_ledger::LedgerValidator::default();
1118        assert!(
1119            matches!(
1120                v.validate_full(&ops),
1121                Err(crate::grammar_ledger::ValidationError::SwapCalcBeforeCredit { .. })
1122            ),
1123            "misordered Plan: V2SwapCalc before its pair seed must be rejected"
1124        );
1125        let _ = U256::ZERO;
1126    }
1127
1128    // BP7KIR Increment 3: the V4 container (`v4_v4`) on the Plan — the
1129    // PM-net-zero master invariant + D0 take-before-credit on the PM ledger.
1130    fn v4_v4_path_inputs() -> (PathInfo, ComposerInputs<'static>) {
1131        // WETH terminal (the spike's proven slice): weth→t1→weth.
1132        v4_v4_inputs(Terminal::Weth, crate::composers::EncodeOptions::default())
1133    }
1134
1135    #[test]
1136    fn v4_v4_plan_byte_parity_and_validates() {
1137        let (path, inputs) = v4_v4_path_inputs();
1138        plan_builds_and_validates(build_v4v4_plan, &path, &inputs, "v4_v4");
1139    }
1140
1141    #[test]
1142    fn v4_v4_plan_take_before_swap_rejected() {
1143        // D0 on the PM ledger: a `V4TakeDelta` before any swap created PM
1144        // credit must be rejected (the `v2_v2_v4` bug class on the PM ledger).
1145        let (path, inputs) = v4_v4_path_inputs();
1146        let (_preamble, mut plan, _at) = build_v4v4_plan(&path, &inputs).expect("v4_v4 build None");
1147        // The V4Unlock's inner is [Swap a, Swap b, TakeDelta, SettleAll].
1148        // Move TakeDelta to the front (before both swaps) → TakeBeforeCredit.
1149        let outer = plan.get_mut(0).unwrap();
1150        if let PlanStep::V4Unlock { inner, .. } = outer {
1151            let take = inner.remove(2);
1152            inner.insert(0, take);
1153        } else {
1154            panic!("expected outer V4Unlock");
1155        }
1156        let ops = plan_to_ledger_ops(&plan);
1157        let mut v = crate::grammar_ledger::LedgerValidator::default();
1158        assert!(
1159            matches!(
1160                v.validate_full(&ops),
1161                Err(crate::grammar_ledger::ValidationError::TakeBeforeCredit { .. })
1162            ),
1163            "misordered v4_v4 Plan: TakeDelta before the swap credits PM must be rejected"
1164        );
1165        let _ = U256::ZERO;
1166    }
1167
1168    #[test]
1169    fn v4_v4_plan_unsettled_delta_rejected() {
1170        // The master V4 invariant: a `V4Unlock` that closes with a nonzero
1171        // `PM[currency]` delta (here: removing the trailing `V4SettleAll` leaves
1172        // a residual t1 delta when forward_out ≠ b_swap_in) must be rejected.
1173        let (path, mut inputs) = v4_v4_path_inputs();
1174        // Force an UNSETTLED t1 DEBT: b_swap_in (1_150_000) > forward_out
1175        // (1_100_000), so removing the trailing SettleAll leaves a NEGATIVE t1
1176        // delta (the executor owes the V4 input) → PmDeltaNonzero at V4UnlockEnd.
1177        // (A POSITIVE residual is on-chain-valid — the unlock-close auto-settles
1178        // it to the executor — so the rejection is specifically the unpaid debt.)
1179        static CLAMPED: [u128; 2] = [1_000_000, 1_150_000];
1180        inputs.consumed_inputs = &CLAMPED;
1181        let (_preamble, mut plan, _at) = build_v4v4_plan(&path, &inputs).expect("v4_v4 build None");
1182        let outer = plan.get_mut(0).unwrap();
1183        if let PlanStep::V4Unlock { inner, .. } = outer {
1184            // Remove the trailing SettleAll — the t1 delta (forward_out −
1185            // b_swap_in = 50_000) is left nonzero → PmDeltaNonzero at V4UnlockEnd.
1186            inner.pop();
1187        } else {
1188            panic!("expected outer V4Unlock");
1189        }
1190        let ops = plan_to_ledger_ops(&plan);
1191        let mut v = crate::grammar_ledger::LedgerValidator::default();
1192        assert!(
1193            matches!(
1194                v.validate_full(&ops),
1195                Err(crate::grammar_ledger::ValidationError::PmDeltaNonzero { .. })
1196            ),
1197            "v4_v4 Plan missing its settle must be rejected: nonzero PM delta at unlock end"
1198        );
1199        let _ = U256::ZERO;
1200    }
1201
1202    // ── BP7KIR opts: `use_v4_batch` + `erc6909_profit` (within the WETH-only
1203    //    slice) — byte-parity with `derive_2hop_v4v4` AND gate validation. ──
1204
1205    fn v4_v4_opts_inputs(
1206        opts: crate::composers::EncodeOptions,
1207    ) -> (PathInfo, ComposerInputs<'static>) {
1208        let (mut path, mut inputs) = v4_v4_path_inputs();
1209        // SAFETY of the `static` borrow: `EncodeOptions` is `Copy` and the
1210        // fixture's `hop_outputs`/`consumed_inputs` are `static`s, so we only
1211        // overwrite the `opts` field — the slice borrows remain valid.
1212        // Re-build `ComposerInputs` with the requested opts.
1213        let ComposerInputs {
1214            executor_address,
1215            pool_manager_address,
1216            weth_address,
1217            optimal_input,
1218            hop_outputs,
1219            consumed_inputs,
1220            opts: _,
1221        } = inputs;
1222        inputs = ComposerInputs {
1223            executor_address,
1224            pool_manager_address,
1225            weth_address,
1226            optimal_input,
1227            hop_outputs,
1228            consumed_inputs,
1229            opts,
1230        };
1231        // Suppress unused-mut on `path` (the fixture path is reused as-is).
1232        let _ = &mut path;
1233        (path, inputs)
1234    }
1235
1236    #[test]
1237    fn v4_v4_plan_batch_byte_parity_and_validates() {
1238        // `use_v4_batch=true`: one `V4Batch` replaces the two `V4Swap`s, and
1239        // NO `V4TakeDelta` is emitted (the batch auto-captures the WETH
1240        // profit). Byte-parity with `derive_2hop_v4v4`'s batch arm.
1241        let (path, inputs) = v4_v4_opts_inputs(crate::composers::EncodeOptions {
1242            erc6909_profit: false,
1243            use_v4_batch: true,
1244            ..Default::default()
1245        });
1246        plan_builds_and_validates(build_v4v4_plan, &path, &inputs, "v4_v4 batch");
1247        // Spot-check the Plan shape: outer `V4Unlock { inner: [V4Batch, V4SettleAll] }`.
1248        let (_preamble, plan, _at) =
1249            build_v4v4_plan(&path, &inputs).expect("v4_v4 batch build None");
1250        let outer = &plan[0];
1251        let PlanStep::V4Unlock { inner, .. } = outer else {
1252            panic!("expected outer V4Unlock");
1253        };
1254        assert_eq!(inner.len(), 2, "batch inner = [V4Batch, V4SettleAll]");
1255        assert!(
1256            matches!(inner[0], PlanStep::V4Batch { .. }),
1257            "first step is V4Batch"
1258        );
1259        assert!(
1260            matches!(inner[1], PlanStep::V4SettleAll),
1261            "trailing SettleAll"
1262        );
1263        let _ = U256::ZERO;
1264    }
1265
1266    #[test]
1267    fn v4_v4_plan_erc6909_byte_parity_and_validates() {
1268        // `erc6909_profit=true`: `V4Mint` of the WETH profit (ERC6909 claim)
1269        // replaces the `V4TakeDelta`.
1270        let (path, inputs) = v4_v4_opts_inputs(crate::composers::EncodeOptions {
1271            erc6909_profit: true,
1272            use_v4_batch: false,
1273            ..Default::default()
1274        });
1275        plan_builds_and_validates(build_v4v4_plan, &path, &inputs, "v4_v4 erc6909");
1276        let (_preamble, plan, _at) =
1277            build_v4v4_plan(&path, &inputs).expect("v4_v4 erc6909 build None");
1278        let PlanStep::V4Unlock { inner, .. } = &plan[0] else {
1279            panic!("expected outer V4Unlock");
1280        };
1281        assert_eq!(
1282            inner.len(),
1283            4,
1284            "erc6909 inner = [V4Swap, V4Swap, V4Mint, V4SettleAll]"
1285        );
1286        assert!(
1287            matches!(inner[2], PlanStep::V4Mint { .. }),
1288            "profit step is V4Mint"
1289        );
1290        let _ = U256::ZERO;
1291    }
1292
1293    #[test]
1294    fn v4_v4_batch_and_erc6909_capture_weth_terminal_composes() {
1295        // TGUZCT/SW42JA: on the deployed artifact the `V4_BATCH_OPEN_WETH`
1296        // (0x43) variant leaves the positive WETH delta OPEN at the batch's
1297        // end, so the follow-up `V4_MINT_COMPACT` converts it into the
1298        // ERC6909 vault. The former decline (SMOZG3, pre-deployment artifact)
1299        // is lifted: the stream composes as [V4Batch(open_weth), V4Mint,
1300        // V4SettleAll] and the pairing gate accepts it.
1301        let (path, inputs) = v4_v4_opts_inputs(crate::composers::EncodeOptions {
1302            erc6909_profit: true,
1303            use_v4_batch: true,
1304            ..Default::default()
1305        });
1306        let (_preamble, plan, _at) = build_v4v4_plan(&path, &inputs)
1307            .expect("batch + erc6909 WETH-terminal must build (TGUZCT)");
1308        let PlanStep::V4Unlock { inner, .. } = &plan[0] else {
1309            panic!("expected outer V4Unlock");
1310        };
1311        assert_eq!(
1312            inner.len(),
1313            3,
1314            "erc6909+batch inner = [V4Batch(open_weth), V4Mint, V4SettleAll]"
1315        );
1316        assert!(
1317            matches!(
1318                &inner[0],
1319                PlanStep::V4Batch {
1320                    open_weth: true,
1321                    ..
1322                }
1323            ),
1324            "the batch must be the open-weth (0x43) variant"
1325        );
1326        assert!(matches!(&inner[1], PlanStep::V4Mint { .. }));
1327        assert!(matches!(&inner[2], PlanStep::V4SettleAll));
1328        // The pairing gate accepts the composed stream.
1329        let ops = plan_to_ledger_ops(&plan);
1330        assert!(
1331            crate::grammar_ledger::LedgerValidator::default()
1332                .validate_full(&ops)
1333                .is_ok(),
1334            "open batch -> WETH mint -> settle-all must validate"
1335        );
1336    }
1337
1338    #[test]
1339    fn v4_v4_plan_mint_before_swap_rejected() {
1340        // `V4Mint` honors the same D0 credit-before-debit rule as `V4TakeDelta`:
1341        // a `V4Mint` positioned before the swaps that create `PM[weth]` credit
1342        // must be rejected (the `Mint` gate op fails with `TakeBeforeCredit`).
1343        let (path, inputs) = v4_v4_opts_inputs(crate::composers::EncodeOptions {
1344            erc6909_profit: true,
1345            use_v4_batch: false,
1346            ..Default::default()
1347        });
1348        let (_preamble, mut plan, _at) =
1349            build_v4v4_plan(&path, &inputs).expect("v4_v4 erc6909 build None");
1350        // The V4Unlock's inner is [Swap a, Swap b, V4Mint, SettleAll].
1351        // Move V4Mint to the front (before both swaps) → TakeBeforeCredit.
1352        let outer = plan.get_mut(0).unwrap();
1353        if let PlanStep::V4Unlock { inner, .. } = outer {
1354            let mint = inner.remove(2);
1355            inner.insert(0, mint);
1356        } else {
1357            panic!("expected outer V4Unlock");
1358        }
1359        let ops = plan_to_ledger_ops(&plan);
1360        let mut v = crate::grammar_ledger::LedgerValidator::default();
1361        assert!(
1362            matches!(
1363                v.validate_full(&ops),
1364                Err(crate::grammar_ledger::ValidationError::TakeBeforeCredit { .. })
1365            ),
1366            "misordered v4_v4 erc6909 Plan: V4Mint before the swap credits PM must be rejected"
1367        );
1368        let _ = U256::ZERO;
1369    }
1370
1371    // ── BP7KIR slice-broaden: the v4_v4 Plan across all 3 terminal currencies
1372    //    (WETH / tok / native) × all 4 opt modes. Byte-parity with the proven
1373    //    `derive_2hop_v4v4` emitter AND gate validation in every cell. ──
1374    #[test]
1375    fn v4_v4_terminal_opt_matrix_byte_parity_and_validates() {
1376        use crate::composers::EncodeOptions;
1377        let modes = [
1378            ("default", EncodeOptions::default()),
1379            (
1380                "batch",
1381                EncodeOptions {
1382                    erc6909_profit: false,
1383                    use_v4_batch: true,
1384                    ..Default::default()
1385                },
1386            ),
1387            (
1388                "erc6909",
1389                EncodeOptions {
1390                    erc6909_profit: true,
1391                    use_v4_batch: false,
1392                    ..Default::default()
1393                },
1394            ),
1395            (
1396                "batch+erc6909",
1397                EncodeOptions {
1398                    erc6909_profit: true,
1399                    use_v4_batch: true,
1400                    ..Default::default()
1401                },
1402            ),
1403        ];
1404        let terminals = [
1405            ("weth", Terminal::Weth),
1406            ("tok", Terminal::Tok),
1407            ("native", Terminal::Native),
1408        ];
1409        for (t_name, terminal) in terminals {
1410            for (m_name, opts) in modes {
1411                let label = format!("v4_v4 {t_name}+{m_name}");
1412                let (path, inputs) = v4_v4_inputs(terminal, opts);
1413                // TGUZCT/SW42JA: the WETH-terminal batch+erc6909 cell composes
1414                // (0x43 open-weth batch feeds the mint) — no decline special case.
1415                plan_builds_and_validates(build_v4v4_plan, &path, &inputs, &label);
1416            }
1417        }
1418        let _ = U256::ZERO;
1419    }
1420
1421    #[test]
1422    fn v4_v4_plan_batch_native_terminal_emits_no_take() {
1423        // The batch asymmetry for a NATIVE terminal: `V4_BATCH` auto-settles the
1424        // positive native PM delta, so the derive emits NO `V4TakeDelta` and
1425        // neither does the Plan — the trailing `V4SettleAll` zeroes the
1426        // residual `PM[native]` (gate's master invariant at `V4UnlockEnd`).
1427        let (path, inputs) = v4_v4_inputs(
1428            Terminal::Native,
1429            crate::composers::EncodeOptions {
1430                erc6909_profit: false,
1431                use_v4_batch: true,
1432                ..Default::default()
1433            },
1434        );
1435        let (_preamble, plan, _at) =
1436            build_v4v4_plan(&path, &inputs).expect("v4_v4 native+batch build None");
1437        let PlanStep::V4Unlock { inner, .. } = &plan[0] else {
1438            panic!("expected outer V4Unlock");
1439        };
1440        assert_eq!(
1441            inner.len(),
1442            2,
1443            "native+batch inner = [V4Batch, V4SettleAll] (no take)"
1444        );
1445        assert!(matches!(inner[0], PlanStep::V4Batch { .. }));
1446        assert!(matches!(inner[1], PlanStep::V4SettleAll));
1447        let _ = U256::ZERO;
1448    }
1449
1450    // ── BP7KIR currency-gap slice: native↔WETH bridge at the mid. Byte-parity
1451    //    with derive_2hop_v4v4's gap branch AND gate validation. ──
1452    #[test]
1453    fn v4_v4_gap_byte_parity_and_validates() {
1454        // Both gap topologies (Wrap + Unwrap), default opts. The gap branch
1455        // emits: swap a → take+bridge → swap b → settle_delta → take(terminal)
1456        // → settle_all.
1457        for (name, gap) in [("wrap", Gap::Wrap), ("unwrap", Gap::Unwrap)] {
1458            let label = format!("v4_v4 gap {name}");
1459            let (path, inputs) = v4_v4_gap_inputs(gap, crate::composers::EncodeOptions::default());
1460            plan_builds_and_validates(build_v4v4_plan, &path, &inputs, &label);
1461        }
1462        let _ = U256::ZERO;
1463    }
1464
1465    #[test]
1466    fn v4_v4_gap_opt_matrix_byte_parity_and_validates() {
1467        // The gap branch is opt-invariant (use_v4_batch + erc6909_profit are
1468        // inoperative across a gap — the derive forces individual swaps + a
1469        // physical take). Sweep all 4 opt modes for both gap topologies.
1470        // TGUZCT gap adjudication (3JNVM3): the 0x43 open-weth batch can never
1471        // apply here — (a) a gap forces the individual-swap path (the batch
1472        // command is issued only for `!any_gap`), and (b) the gap twin's
1473        // terminal is non-WETH, so the erc6909 mint path (the only
1474        // `open_weth` producer) never fires. A WETH-terminal gap+batch+
1475        // erc6909 request composes as individual swaps + mint (byte-identical
1476        // to the non-batch capture shape) — no 0x43, no decline.
1477        use crate::composers::EncodeOptions;
1478        let modes = [
1479            ("default", EncodeOptions::default()),
1480            (
1481                "batch",
1482                EncodeOptions {
1483                    erc6909_profit: false,
1484                    use_v4_batch: true,
1485                    ..Default::default()
1486                },
1487            ),
1488            (
1489                "erc6909",
1490                EncodeOptions {
1491                    erc6909_profit: true,
1492                    use_v4_batch: false,
1493                    ..Default::default()
1494                },
1495            ),
1496            (
1497                "batch+erc6909",
1498                EncodeOptions {
1499                    erc6909_profit: true,
1500                    use_v4_batch: true,
1501                    ..Default::default()
1502                },
1503            ),
1504        ];
1505        for (g_name, gap) in [("wrap", Gap::Wrap), ("unwrap", Gap::Unwrap)] {
1506            for (m_name, opts) in modes {
1507                let label = format!("v4_v4 gap {g_name}+{m_name}");
1508                let (path, inputs) = v4_v4_gap_inputs(gap, opts);
1509                plan_builds_and_validates(build_v4v4_plan, &path, &inputs, &label);
1510            }
1511        }
1512        let _ = U256::ZERO;
1513    }
1514
1515    #[test]
1516    fn v4_v4_gap_shape_is_swap_bridge_swap_settle_take() {
1517        // Plan-shape spot check: the gap branch lays out
1518        // [V4Swap, V4TakeCompact, (WethDeposit|WethWithdraw), V4Swap,
1519        //  V4SettleDelta, V4TakeDelta, V4SettleAll].
1520        let (path, inputs) =
1521            v4_v4_gap_inputs(Gap::Wrap, crate::composers::EncodeOptions::default());
1522        let (_preamble, plan, _at) = build_v4v4_plan(&path, &inputs).expect("v4_v4 gap build None");
1523        let PlanStep::V4Unlock { inner, .. } = &plan[0] else {
1524            panic!("expected outer V4Unlock");
1525        };
1526        assert_eq!(inner.len(), 7, "gap inner = 7 steps");
1527        assert!(matches!(inner[0], PlanStep::V4Swap { .. }), "0: swap a");
1528        assert!(
1529            matches!(inner[1], PlanStep::V4TakeCompact { .. }),
1530            "1: bridge take"
1531        );
1532        assert!(
1533            matches!(inner[2], PlanStep::WethDeposit { .. }),
1534            "2: wrap deposit (Wrap gap)"
1535        );
1536        assert!(matches!(inner[3], PlanStep::V4Swap { .. }), "3: swap b");
1537        assert!(
1538            matches!(inner[4], PlanStep::V4SettleDelta { .. }),
1539            "4: settle"
1540        );
1541        assert!(
1542            matches!(inner[5], PlanStep::V4TakeDelta { .. }),
1543            "5: profit take"
1544        );
1545        assert!(matches!(inner[6], PlanStep::V4SettleAll), "6: settle all");
1546        let _ = U256::ZERO;
1547    }
1548    // ── WE45KC inc.2: ProfitCapture::Native on v4_v4 (ADR-029 D1) ──────────
1549    // The capture axis is now load-bearing in the encoder: a WETH-terminal
1550    // v4_v4 path with capture=Native appends a WETH_WITHDRAW (0x13) converting
1551    // the profit to native ETH after the V4_TAKE_DELTA custody take. A
1552    // non-WETH/non-native tok terminal + Native is declined (the executor
1553    // cannot convert an arbitrary ERC-20 to native). A native terminal +
1554    // Native is a no-op (already native custody).
1555
1556    /// Collect every `WETH_WITHDRAW` (0x13) command's 32-byte amount payload
1557    /// in `bytes` (scans all windows; `0x13` may appear in address bytes, so
1558    /// the caller asserts the expected profit is among the payloads).
1559    fn weth_withdraw_amounts(bytes: &[u8]) -> Vec<u128> {
1560        bytes
1561            .windows(33)
1562            .filter(|w| w[0] == 0x13)
1563            .map(|w| {
1564                let mut a = [0u8; 16];
1565                a.copy_from_slice(&w[17..33]);
1566                u128::from_be_bytes(a)
1567            })
1568            .collect()
1569    }
1570
1571    /// Count `V4_TAKE_DELTA` (0x50) commands in `bytes`.
1572    fn count_v4_take_delta(bytes: &[u8]) -> usize {
1573        bytes.iter().filter(|&&b| b == 0x50).count()
1574    }
1575
1576    #[test]
1577    fn v4_v4_native_capture_weth_terminal_appends_weth_withdraw() {
1578        // capture=Native: WETH-terminal path takes WETH to custody, then
1579        // WETH_WITHDRAW(profit) converts it to native. profit = weth_out -
1580        // optimal_input = 1_200_000 - 1_000_000 = 200_000.
1581        let (path, inputs) = v4_v4_opts_inputs(crate::composers::EncodeOptions {
1582            capture: crate::grammar_ledger::ProfitCapture::Native,
1583            ..Default::default()
1584        });
1585        let bytes =
1586            derive_shape(&path, &inputs).expect("v4_v4 native-capture WETH terminal must derive");
1587        assert!(
1588            weth_withdraw_amounts(&bytes).contains(&200_000),
1589            "Native capture must append WETH_WITHDRAW of the profit; got {:?}",
1590            weth_withdraw_amounts(&bytes)
1591        );
1592        // The custody take is still emitted (WETH taken to executor first).
1593        assert!(
1594            count_v4_take_delta(&bytes) >= 1,
1595            "V4_TAKE_DELTA custody take must precede the withdraw"
1596        );
1597    }
1598
1599    #[test]
1600    fn v4_v4_custody_capture_emits_no_weth_withdraw() {
1601        // Default (Custody): no WETH_WITHDRAW — profit held as WETH.
1602        let (path, inputs) = v4_v4_opts_inputs(crate::composers::EncodeOptions::default());
1603        let bytes = derive_shape(&path, &inputs).expect("v4_v4 custody WETH terminal must derive");
1604        assert!(
1605            !weth_withdraw_amounts(&bytes).contains(&200_000),
1606            "Custody capture must NOT append a WETH_WITHDRAW of the profit"
1607        );
1608    }
1609
1610    #[test]
1611    fn v4_v4_native_capture_tok_terminal_declines() {
1612        // capture=Native, tok terminal: the executor cannot convert an
1613        // arbitrary ERC-20 to native → derive declines (ADR-029 D1: declared
1614        // but not executable).
1615        let (path, inputs) = v4_v4_inputs(
1616            Terminal::Tok,
1617            crate::composers::EncodeOptions {
1618                capture: crate::grammar_ledger::ProfitCapture::Native,
1619                ..Default::default()
1620            },
1621        );
1622        assert!(
1623            derive_shape(&path, &inputs).is_none(),
1624            "Native capture on a non-WETH/non-native tok terminal must decline"
1625        );
1626    }
1627
1628    #[test]
1629    fn v4_v4_native_capture_native_terminal_is_noop() {
1630        // capture=Native, native terminal: profit is already native custody
1631        // (V4_TAKE_DELTA(native_idx, SELF)); no WETH_WITHDRAW needed. Derives.
1632        let (path, inputs) = v4_v4_inputs(
1633            Terminal::Native,
1634            crate::composers::EncodeOptions {
1635                capture: crate::grammar_ledger::ProfitCapture::Native,
1636                ..Default::default()
1637            },
1638        );
1639        let bytes = derive_shape(&path, &inputs)
1640            .expect("v4_v4 native-capture native terminal must derive (no-op)");
1641        assert!(
1642            !weth_withdraw_amounts(&bytes).iter().any(|&a| a > 0),
1643            "Native terminal + Native capture is already native; no withdraw"
1644        );
1645    }
1646    #[test]
1647    fn v4_v4_native_capture_plan_byte_parity_and_validates() {
1648        // WE45KC inc.2: the Native-capture Plan (WETH terminal → V4TakeDelta
1649        // custody + WethWithdraw) stays byte-identical to derive_2hop_v4v4 AND
1650        // validates clean through the ledger gate (D5). The custody credit on
1651        // V4TakeDelta(→SELF) now models the executor's receipt, so the
1652        // withdraw debits a real Erc20[WETH] balance.
1653        let (path, inputs) = v4_v4_opts_inputs(crate::composers::EncodeOptions {
1654            capture: ProfitCapture::Native,
1655            ..Default::default()
1656        });
1657        plan_builds_and_validates(build_v4v4_plan, &path, &inputs, "v4_v4 native-capture");
1658    }
1659}