Skip to main content

degenbot_executor/
grammar_walker.rs

1//! ADR-031 D6 — the sole facts-driven Plan producer .
2//!
3//! The pipeline has three stages:
4//!
5//! 1. **Hop facts** (data): per-protocol [`HopFacts`] descriptors produced by
6//!    [`hop_facts`] (the default per-hop mapping) or one of five per-position
7//!    override fns (`facts_of_*`). [`facts_for`] is the single dispatcher that
8//!    routes each path to the right facts source.
9//! 2. **Mechanics** (code): [`derive_plan`] is the shape dispatcher over the
10//!    per-enclosure-block modules in `grammar_walker/shapes/*.rs` — it reads
11//!    the `Repay`/`OutDest` facts tags to determine which `FlashSwap`/
12//!    `V4Unlock` wraps which, the repayment order, and the capture arms.
13//! 3. **Validator gate**: lives in `grammar_shape` (`derive_shape_detailed`):
14//!    build → `plan_to_ledger_ops` → `LedgerValidator::validate_full` →
15//!    `preamble + plan_to_bytes`.
16//!
17//! [`build_walk`] is the single pipeline entry: `facts_for` → `derive_plan`
18//! → `enc_preamble`, returning `(preamble, plan, at)`. The
19//! `LedgerValidator` gate (one representation) is reused unchanged: the
20//! walker emits exactly one `Plan`, and the encoder + validator are pure
21//! functions of it. Structural + behavioral parity with the pre-refactor
22//! reference producer is pinned by the revm contract matrix + the
23//! `spike_derivation` golden suite.
24
25use crate::composers::{ComposerInputs, HopInfo, PathInfo, V2HopInfo, V3HopInfo, V4HopInfo};
26use crate::encoders::AddressTable;
27use crate::grammar_ledger::Prot;
28use crate::grammar_plan::{v2_forward, v3_forward, v3_input, Plan};
29use crate::grammar_shape::v4_hop_currencies;
30use alloy::primitives::Address;
31
32/// Whether an amount fits the on-chain i128 swap-input field.
33fn fits_i128(v: u128) -> bool {
34    v <= i128::MAX as u128
35}
36
37/// Where a hop's swap output is routed (the hop-coupling fact).
38#[derive(Clone, Copy, PartialEq, Eq, Debug)]
39pub enum OutDest {
40    /// Credits the executor.
41    Executor,
42    /// Routed into the PoolManager (seeds the V4 unlock ledger).
43    PoolManager,
44    /// Taken to a pool to REPAY its flash borrow.
45    Repay(Address),
46}
47
48/// How a hop's borrowed input is repaid (the repayment-obligation fact).
49#[derive(Clone, Copy, PartialEq, Eq, Debug)]
50pub enum Repay {
51    /// Repaid with a currency by an explicit transfer in its own callback.
52    SelfRefund,
53    /// Repaid off-stream by a downstream hop's take to this pool.
54    Offstream,
55    /// No borrow to repay (a V4 middle nets to zero inside its unlock).
56    NetZero,
57}
58
59/// The terminal-form axis (T5): how the trailing hop of a
60/// V4-containing 3-hop shape completes its stream. `DirectHandoff` — the
61/// trailing swap completes on its own pool and hands output to SELF (the
62/// v3v4v2 trailing `v2_swap`). `UnlockInternal` — the trailing swap is an op
63/// inside the enclosing V4Unlock's inner (the v3v4v4 trailing V4Swap); the
64/// unlock's ledger settlements are sequenced by the shape, not by the
65/// trailing hop alone.
66///
67/// `None` for every non-terminal hop and for terminal hops in shapes that
68/// don't consume the axis. Set exactly once per family, by the facts
69/// dispatcher's terminal-position override; consumed only by the merged
70/// v3v4[v2|v4] arm in [`shapes::three_hop`].
71#[derive(Clone, Copy, PartialEq, Eq, Debug)]
72pub enum TerminalForm {
73    /// The trailing swap is a direct pool swap; its output leaves the
74    /// unlock's accounting.
75    DirectHandoff,
76    /// The trailing swap lives inside the enclosing V4Unlock's inner; its
77    /// deltas settle through the unlock.
78    UnlockInternal,
79}
80
81/// The **repay-mechanism** axis (T6c): how a flash hop's borrowed
82/// input is repaid, AND the timing of the draw relative to the callback.
83/// The existing [`Repay`] tag fixes the *obligation category* (who owes what)
84/// but is identical for the V2 flash in `v3v2v4` (forward nest, draws the
85/// repay at borrow — `auto_repay=true`) and the V2 flash in `v2v4v2`
86/// (reverse nest, repays in-callback). This sub-fact disambiguates the two —
87/// only the forward-nesting family needs it.
88///
89/// `None` (the default) for every flash hop except where a consumer family
90/// needs the timing distinction: scoped exactly like [`TerminalForm`]. Set
91/// only by the facts dispatcher's per-position override; consumed only by the
92/// group-C arm of [`shapes::three_hop`].
93#[derive(Clone, Copy, PartialEq, Eq, Debug)]
94pub enum RepayMechanism {
95    /// The flash draws its repay at borrow (pre-callback) — `auto_repay=true`.
96    /// Only the `v3v2v4` V2 flash (the sole forward-nested arm): the seeder
97    /// flash must run first (outer), so the leading V3 flash wraps it.
98    AutoFromExecutor,
99    /// Repaid by an explicit transfer inside the flash's own callback.
100    TransferInCallback,
101    /// Repaid by a `V4TakeCompact`/`V4TakeDelta` inside the enclosing V4Unlock.
102    V4TakeInUnlock,
103    /// The repay currency is delivered by a downstream flash's forward.
104    DownstreamFlashDelivery,
105    /// The repay currency is delivered by a downstream non-flash take (seed).
106    DownstreamTakeSeeds,
107}
108
109/// The **seed-delivery** axis (T6c): how a WETH prefund (the optimal
110/// seed that funds a leading V2/V3 calc) is emitted. `Erc20Transfer` (the
111/// default) is the plain pre-callback transfer; `V4TakeCompact` emits the
112/// prefund as a `V4TakeCompact` *inside* the active V4Unlock's delta ledger
113/// (because the seed currency is a V4-managed WETH delta), plus a matching
114/// profit-take to SELF. Only the `v2v3v4` family needs the V4-ledger variant.
115///
116/// `None` (the default) for every hop except where a consumer family needs
117/// it: scoped exactly like [`TerminalForm`]. Set only by the facts
118/// dispatcher's per-position override; consumed only by the group-C arm of
119/// [`shapes::three_hop`].
120#[derive(Clone, Copy, PartialEq, Eq, Debug)]
121pub enum SeedDelivery {
122    /// The prefund is a plain `Erc20Transfer` in the flash callback.
123    Erc20Transfer,
124    /// The prefund is a `V4TakeCompact` inside the enclosing V4Unlock.
125    V4TakeCompact,
126}
127
128/// Per-protocol **hop facts** — the ADR-031 D4 data half: ledgers a hop
129/// touches, direction, output slot, and repayment obligation. The walker
130/// derives the enclosure from these; the mechanics (the swap/callback step) is
131/// per-protocol code below.
132pub struct HopFacts {
133    pub prot: Prot,
134    pub zfo: bool,
135    pub swap_fee: u16,
136    pub tick_spacing: i16,
137    pub out_currency: Address,
138    pub in_currency: Address,
139    pub out_dest: OutDest,
140    pub repay: Repay,
141    /// The V2/V3 pool, or the V4 pool-manager — the mechanics' pool identity.
142    pub pool_address: Address,
143    /// V4 only — the pool-id hex. `None` for V2/V3.
144    pub pool_id_hex: Option<String>,
145    /// V4 only — currency0 / currency1.
146    pub currency0_address: Address,
147    pub currency1_address: Address,
148    /// [`TerminalForm`] — set on the terminal hop only when a shape consumes it (see enum).
149    pub terminal_form: Option<TerminalForm>,
150    /// [`RepayMechanism`] — set on a flash hop only when a shape needs the
151    /// repay timing distinction (see enum). `None` everywhere else.
152    pub repay_mechanism: Option<RepayMechanism>,
153    /// [`SeedDelivery`] — set on the seeded hop only when a shape needs the
154    /// prefund-mechanism distinction (see enum). `None` everywhere else.
155    pub seed_delivery: Option<SeedDelivery>,
156}
157
158/// Per-protocol **mechanics** (ADR-031 D4 code half): how a protocol's hop
159/// becomes a `PlanStep` tree. For the spike only the V3 mechanics the
160/// `v3_v4_v3` shape exercises is implemented; A2 generalizes.
161mod mechanics {
162    use super::{AddressTable, HopFacts, OutDest};
163    use crate::encoders::{SENTINEL_NATIVE, SENTINEL_PM, SENTINEL_SELF};
164    use crate::grammar_ledger::Prot;
165    use crate::grammar_plan::{Plan, PlanStep, V4BatchSwap};
166    use alloy::primitives::Address;
167
168    /// The V3 flash-swap step, built from the hop's facts. `pool_address` +
169    /// `zfo` come from the facts. Recipient routing: `None` derives from
170    /// `facts.out_dest` (Executor → SELF, PoolManager → PM); `Some((idx,
171    /// pool_addr, pool_repays))` sets it explicitly — the 3-hop nested-flash
172    /// families (T5) route a flash's repayment to a downstream recipient pool
173    /// (`pool_repays`), which the out-derivation cannot express.
174    /// Single primitive since T2  folded the old
175    /// `v3_flash`/`v3_flash_to` pair; byte-identity pinned by the glopcn
176    /// goldens.
177    pub fn v3_flash(
178        at: &mut AddressTable,
179        facts: &HopFacts,
180        out_amount: u128,
181        in_amount: u128,
182        auto_repay: bool,
183        recipient: Option<(u8, Option<Address>, bool)>,
184        callback: Vec<PlanStep>,
185    ) -> Option<PlanStep> {
186        let (recipient_idx, recipient_pool_addr, recipient_pool_repays) = match recipient {
187            Some(r) => r,
188            None => match facts.out_dest {
189                OutDest::Executor => (SENTINEL_SELF, None, false),
190                OutDest::PoolManager => (SENTINEL_PM, None, false),
191                OutDest::Repay(_) => unreachable!("V3 hop never repays a pool here"),
192            },
193        };
194        Some(PlanStep::FlashSwap {
195            pool_idx: at.add(facts.pool_address).ok()?,
196            pool_addr: facts.pool_address,
197            protocol: Prot::V3,
198            zfo: facts.zfo,
199            fee: facts.swap_fee,
200            out_currency: facts.out_currency,
201            out_amount,
202            in_currency: facts.in_currency,
203            in_amount,
204            recipient_idx,
205            recipient_pool_addr,
206            recipient_pool_repays,
207            auto_repay,
208            callback,
209        })
210    }
211
212    /// The V2 forward-swap step — a `V2SwapCalc` (the terminal-V2 exact-draw
213    /// rule: swap from whatever the feeder delivered to the pair, never an
214    /// exact-out `V2_SWAP_COMPACT`). `recipient` routing is positional (the
215    /// next pool in the chain, or SENTINEL_SELF for a terminal), so it is
216    /// passed explicitly rather than derived from `out_dest`.
217    pub fn v2_swap(
218        at: &mut AddressTable,
219        facts: &HopFacts,
220        out_amount: u128,
221        recipient_idx: u8,
222        recipient_pool_addr: Option<Address>,
223        recipient_repays: bool,
224    ) -> Option<PlanStep> {
225        Some(PlanStep::V2SwapCalc {
226            pool_idx: at.add(facts.pool_address).ok()?,
227            pool_addr: facts.pool_address,
228            zfo: facts.zfo,
229            recipient_idx,
230            fee: facts.swap_fee,
231            out_currency: facts.out_currency,
232            out_amount,
233            recipient_pool_addr,
234            recipient_repays,
235        })
236    }
237
238    /// The V2 flash-swap step — a `FlashSwap { protocol: Prot::V2 }`.
239    /// `out_dest` picks the recipient routing (mirrors `v3_flash`).
240    pub fn v2_flash(
241        at: &mut AddressTable,
242        facts: &HopFacts,
243        out_amount: u128,
244        in_currency: Address,
245        in_amount: u128,
246        callback: Vec<PlanStep>,
247    ) -> Option<PlanStep> {
248        let pool_idx = at.add(facts.pool_address).ok()?;
249        let (recipient_idx, recipient_pool_addr, recipient_pool_repays) = match facts.out_dest {
250            OutDest::Executor => (SENTINEL_SELF, None, false),
251            OutDest::PoolManager => (SENTINEL_PM, None, false),
252            OutDest::Repay(addr) => (at.add(addr).ok()?, Some(addr), false),
253        };
254        Some(PlanStep::FlashSwap {
255            pool_idx,
256            pool_addr: facts.pool_address,
257            protocol: Prot::V2,
258            zfo: facts.zfo,
259            fee: facts.swap_fee,
260            out_currency: facts.out_currency,
261            out_amount,
262            in_currency,
263            in_amount,
264            recipient_idx,
265            recipient_pool_addr,
266            recipient_pool_repays,
267            auto_repay: false,
268            callback,
269        })
270    }
271
272    /// The V2 direct-forward swap routed to an explicit recipient whose flash
273    /// it repays (`recipient_repays`). The 3-hop V2-leading families (T5) use
274    /// `V2SwapDirect` — the recipient pool's flash is repaid by this swap's
275    /// forward, so the executor never hands the token to the recipient.
276    pub fn v2_swap_direct(
277        at: &mut AddressTable,
278        facts: &HopFacts,
279        out_amount: u128,
280        out_currency: Address,
281        recipient_idx: u8,
282        recipient_pool_addr: Option<Address>,
283        recipient_repays: bool,
284    ) -> Option<PlanStep> {
285        Some(PlanStep::V2SwapDirect {
286            pool_idx: at.add(facts.pool_address).ok()?,
287            pool_addr: facts.pool_address,
288            zfo: facts.zfo,
289            out_amount,
290            recipient_idx,
291            out_currency,
292            recipient_pool_addr,
293            recipient_repays,
294        })
295    }
296
297    // ── V4 mechanics (ADR-031 D4 code half) ──────────────────────────────
298
299    /// The V4 swap step, parameterized by the hop's facts (c0/c1 from the
300    /// currency pair, fee + tick_spacing from the facts).
301    pub fn v4_swap(
302        at: &mut AddressTable,
303        facts: &HopFacts,
304        amount_in: u128,
305        out_amount: u128,
306    ) -> Option<PlanStep> {
307        Some(PlanStep::V4Swap {
308            c0_idx: at.add(facts.currency0_address).ok()?,
309            c1_idx: at.add(facts.currency1_address).ok()?,
310            fee: facts.swap_fee,
311            tick_spacing: facts.tick_spacing,
312            hooks_idx: SENTINEL_NATIVE,
313            zfo: facts.zfo,
314            amount: amount_in,
315            in_currency: facts.in_currency,
316            in_amount: amount_in,
317            out_currency: facts.out_currency,
318            out_amount,
319        })
320    }
321
322    /// A V4 unlock wrapper — nests the unlock interior (`inner`) at the given
323    /// pool-manager index.
324    pub fn v4_unlock(inner: Plan, pool_manager_idx: u8) -> PlanStep {
325        PlanStep::V4Unlock {
326            inner,
327            pool_manager_idx,
328        }
329    }
330
331    /// A V4 take-to-repay step: take the hop's `out_currency` (the ledger the
332    /// PM credits at swap) to `recipient_idx`, repaying its flash debt when
333    /// `repays_flash` is set.
334    pub fn v4_take_compact(
335        at: &mut AddressTable,
336        facts: &HopFacts,
337        recipient_idx: u8,
338        amount: u128,
339        repays_flash: Option<Address>,
340    ) -> Option<PlanStep> {
341        Some(PlanStep::V4TakeCompact {
342            currency_idx: at.add(facts.out_currency).ok()?,
343            currency_addr: facts.out_currency,
344            recipient_idx,
345            amount,
346            seeds_pool: None,
347            repays_flash,
348        })
349    }
350
351    /// Settle a currency into the pool-manager ledger (credit the forward).
352    pub fn v4_settle(currency_addr: Address, amount: u128) -> PlanStep {
353        PlanStep::V4Settle {
354            currency_addr,
355            amount,
356        }
357    }
358
359    /// Net every pool-manager ledger to zero (the unlock exit).
360    pub fn v4_settle_all() -> PlanStep {
361        PlanStep::V4SettleAll
362    }
363
364    /// A V4 sync step — the forward-settle prelude's first op (the pool's
365    /// accounting state pinned against the forward currency's ledger).
366    pub fn v4_sync(currency_idx: u8, currency_addr: Address) -> PlanStep {
367        PlanStep::V4Sync {
368            currency_idx,
369            currency_addr,
370        }
371    }
372
373    /// A plain ERC20 transfer at explicit table indices — the shape owns the
374    /// routing. `repays_flash: Some(pool)` marks a flash-repayment transfer.
375    pub fn erc20_transfer(
376        token_idx: u8,
377        token_addr: Address,
378        recipient_idx: u8,
379        amount: u128,
380        seeds_pool: Option<Address>,
381        repays_flash: Option<Address>,
382    ) -> PlanStep {
383        PlanStep::Erc20Transfer {
384            token_idx,
385            token_addr,
386            recipient_idx,
387            amount,
388            seeds_pool,
389            repays_flash,
390        }
391    }
392
393    /// A native (ETH) transfer — the V2 swap-in draw inside a flash callback.
394    pub fn native_transfer(amount: u128) -> PlanStep {
395        PlanStep::NativeTransfer { amount }
396    }
397
398    /// Net one pool-manager ledger to zero at an explicit index — the caller
399    /// owns which ledger (input ledger, WETH, or the NATIVE bridge).
400    pub fn v4_settle_delta(currency_idx: u8, currency_addr: Address) -> PlanStep {
401        PlanStep::V4SettleDelta {
402            currency_idx,
403            currency_addr,
404        }
405    }
406
407    /// A V4 take-delta step — move the PM ledger's residual to a recipient
408    /// (a tok terminal's explicit take; the V4-tail batch path auto-settles
409    /// WETH/Native so emits none).
410    pub fn v4_take_delta(
411        currency_idx: u8,
412        currency_addr: Address,
413        recipient_idx: u8,
414        seeds_pool: Option<Address>,
415    ) -> PlanStep {
416        PlanStep::V4TakeDelta {
417            currency_idx,
418            currency_addr,
419            recipient_idx,
420            seeds_pool,
421        }
422    }
423
424    /// A V4TakeCompact at explicit table indices — positional (the shape
425    /// owns the routing, including takes whose currency is not the hop's
426    /// `out_currency`, like the V4-led arms' native take).
427    pub fn v4_take_compact_at(
428        currency_idx: u8,
429        currency_addr: Address,
430        recipient_idx: u8,
431        amount: u128,
432        seeds_pool: Option<Address>,
433        repays_flash: Option<Address>,
434    ) -> PlanStep {
435        PlanStep::V4TakeCompact {
436            currency_idx,
437            currency_addr,
438            recipient_idx,
439            amount,
440            seeds_pool,
441            repays_flash,
442        }
443    }
444
445    /// One entry of a [`PlanStep::V4Batch`] — the hop's facts supply fee /
446    /// tick_spacing / zfo / out-currency; the shape stages the table and
447    /// passes the pair indices + the (possibly bridged) input currency.
448    pub fn v4_batch_entry(
449        facts: &HopFacts,
450        c0_idx: u8,
451        c1_idx: u8,
452        amount: u128,
453        out_amount: u128,
454        in_currency: Address,
455    ) -> V4BatchSwap {
456        V4BatchSwap {
457            c0_idx,
458            c1_idx,
459            fee: facts.swap_fee,
460            tick_spacing: facts.tick_spacing,
461            hooks_idx: SENTINEL_NATIVE,
462            zfo: facts.zfo,
463            amount,
464            in_currency,
465            in_amount: amount,
466            out_currency: facts.out_currency,
467            out_amount,
468        }
469    }
470
471    /// Deposit WETH into the V4 pool (the v2v4 native-out in-callback arm).
472    pub fn weth_deposit(weth_idx: u8, weth_addr: Address, amount: u128) -> PlanStep {
473        PlanStep::WethDeposit {
474            weth_idx,
475            weth_addr,
476            amount,
477        }
478    }
479
480    /// Withdraw WETH from the V4 pool (the native-in arms' callback head).
481    pub fn weth_withdraw(weth_idx: u8, weth_addr: Address, amount: u128) -> PlanStep {
482        PlanStep::WethWithdraw {
483            weth_idx,
484            weth_addr,
485            amount,
486        }
487    }
488
489    /// A self-fund — top-level funding of the flash input from the executor's
490    /// own balance (the plan head, before the lead flash).
491    pub fn self_fund(currency: Address, amount: u128) -> PlanStep {
492        PlanStep::SelfFund { currency, amount }
493    }
494
495    /// A batched V4 swap (the `use_v4_batch` optimization) over the hop —
496    /// `entries` carry per-swap currency/fee/tick_spacing; the pool-manager
497    /// index is implicit.
498    pub fn v4_batch(
499        at: &mut AddressTable,
500        facts: &HopFacts,
501        entries: Vec<V4BatchSwap>,
502        open_weth: bool,
503    ) -> PlanStep {
504        // `currency`/`fee`/`tick_spacing` ride on the entries; the pool-manager
505        // index is implicit. Kept as a thin wrapper so the mechanics surface is
506        // homogeneous (T7 wires the `v4_v4`/`v4_v4_v4` batch paths through it).
507        // `open_weth`: 0x43 variant — the WETH tail-settle is skipped, so a
508        // trailing mint (erc6909 capture) finds the live delta (TGUZCT/SW42JA).
509        let _ = (at, facts);
510        PlanStep::V4Batch { entries, open_weth }
511    }
512}
513
514mod shapes;
515
516/// Shared per-family facts helper: the per-protocol default mapping with the
517/// strict fee semantics the `facts_of_*` producers have always had (a fee
518/// that overflows `u16` declines the path — the default mapping zeros it,
519/// which would mis-encode the swap).
520fn v3_hop_facts_strict(h: &V3HopInfo) -> Option<HopFacts> {
521    let mut f = v3_hop_facts(h);
522    f.swap_fee = u16::try_from(h.fee).ok()?;
523    Some(f)
524}
525
526/// The 2-hop terminal override: the closing WETH as the terminal's output,
527/// the leading forward as its input, off-stream repayment (a terminal hop
528/// borrows nothing).
529fn closing_hop(mut f: HopFacts, forward: Address, weth: Address) -> HopFacts {
530    f.out_currency = weth;
531    f.in_currency = forward;
532    f.repay = Repay::Offstream;
533    f
534}
535
536/// Read the `v3_v4_v3` per-protocol facts (the D4 data half).
537pub(crate) fn facts_of_v3v4v3(
538    path: &PathInfo,
539    _inputs: &ComposerInputs<'_>,
540) -> Option<Vec<HopFacts>> {
541    let (HopInfo::V3(a), HopInfo::V4(b), HopInfo::V3(c)) =
542        (&path.hops[0], &path.hops[1], &path.hops[2])
543    else {
544        return None;
545    };
546    let mut fa = v3_hop_facts_strict(a)?;
547    fa.out_dest = OutDest::PoolManager; // the leading V3 seeds the PM for the unlock
548    let mut fb = v4_hop_facts_netzero(b);
549    fb.swap_fee = u16::try_from(b.fee).ok()?;
550    fb.tick_spacing = i16::try_from(b.tick_spacing).ok()?;
551    fb.out_dest = OutDest::Repay(c.pool_address); // take to the terminal V3, repaying its flash
552    let mut fc = v3_hop_facts_strict(c)?;
553    fc.repay = Repay::Offstream; // the terminal borrows nothing (the default is SelfRefund)
554    Some(vec![fa, fb, fc])
555}
556
557/// The 2-hop leading-V3-flash shape (`v3v2`, `v3v3`) — a SelfFund + a leading
558/// V3 flash; the terminal is a V2 `V2SwapCalc` from the seeded forward
559/// (`v3v2`) or a V3 flash with `auto_repay` (`v3v3`). Both families share
560/// this shape; `facts[1].prot` picks the terminal mechanics (ADR-031 D3/D6).
561///
562/// The 2-hop V2→V3 facts (`v2v3`, funding-branched): the terminal's
563/// `out_currency` is the closing WETH; its `in_currency` is the leading
564/// forward.
565pub(crate) fn facts_of_v2v3(path: &PathInfo, inputs: &ComposerInputs<'_>) -> Option<Vec<HopFacts>> {
566    if path.hops.len() != 2 {
567        return None;
568    }
569    let (HopInfo::V2(a), HopInfo::V3(b)) = (&path.hops[0], &path.hops[1]) else {
570        return None;
571    };
572    let terminal = closing_hop(v3_hop_facts_strict(b)?, v2_forward(a), inputs.weth_address);
573    Some(vec![v2_hop_facts(a), terminal])
574}
575
576/// The any-N all-V2 facts (`all_v2`, funding-branched). Every hop is the
577/// default V2 mapping; the chain is the whole stream (no closing WETH is
578/// baked in).
579pub(crate) fn facts_of_all_v2(
580    path: &PathInfo,
581    _inputs: &ComposerInputs<'_>,
582) -> Option<Vec<HopFacts>> {
583    let v2s = path
584        .hops
585        .iter()
586        .map(|h| match h {
587            HopInfo::V2(h) => Some(v2_hop_facts(h)),
588            _ => None,
589        })
590        .collect::<Option<Vec<_>>>()?;
591    if v2s.len() < 2 {
592        return None;
593    }
594    Some(v2s)
595}
596
597/// The 2-hop V3→V3 facts (`v3v3`, SelfFund, two V3 flashes). The terminal's
598/// `out_currency` is the closing WETH (the hand-authored producer hardcodes
599/// `weth` for the terminal V3); `closing_hop` applies that.
600pub(crate) fn facts_of_v3v3(path: &PathInfo, inputs: &ComposerInputs<'_>) -> Option<Vec<HopFacts>> {
601    if path.hops.len() != 2 {
602        return None;
603    }
604    let (HopInfo::V3(a), HopInfo::V3(b)) = (&path.hops[0], &path.hops[1]) else {
605        return None;
606    };
607    let lead = v3_hop_facts_strict(a)?;
608    let terminal = closing_hop(v3_hop_facts_strict(b)?, v3_forward(a), inputs.weth_address);
609    Some(vec![lead, terminal])
610}
611
612/// The 2-hop V3→V2 facts (`v3v2`, SelfFund, terminal V2 swap). The terminal
613/// V2 `V2SwapCalc` outputs the closing WETH (`inputs.weth_address`).
614pub(crate) fn facts_of_v3v2(path: &PathInfo, inputs: &ComposerInputs<'_>) -> Option<Vec<HopFacts>> {
615    if path.hops.len() != 2 {
616        return None;
617    }
618    let (HopInfo::V3(a), HopInfo::V2(b)) = (&path.hops[0], &path.hops[1]) else {
619        return None;
620    };
621    let lead = v3_hop_facts_strict(a)?;
622    let terminal = closing_hop(v2_hop_facts(b), v3_forward(a), inputs.weth_address);
623    Some(vec![lead, terminal])
624}
625
626/// A V3 hop's facts (shared by the V3-involving shape derivers).
627pub(crate) fn v3_hop_facts(h: &V3HopInfo) -> HopFacts {
628    HopFacts {
629        prot: Prot::V3,
630        zfo: h.zfo,
631        swap_fee: u16::try_from(h.fee).unwrap_or(0),
632        tick_spacing: 0,
633        out_currency: v3_forward(h),
634        in_currency: v3_input(h),
635        out_dest: OutDest::Executor,
636        repay: Repay::SelfRefund,
637        pool_address: h.pool_address,
638        pool_id_hex: None,
639        terminal_form: None,
640        repay_mechanism: None,
641        seed_delivery: None,
642        currency0_address: h.token0_address,
643        currency1_address: h.token1_address,
644    }
645}
646
647/// A V2 hop's facts (shared by the V2-involving shape derivers).
648pub(crate) fn v2_hop_facts(h: &V2HopInfo) -> HopFacts {
649    HopFacts {
650        prot: Prot::V2,
651        zfo: h.zfo,
652        swap_fee: h.fee,
653        tick_spacing: 0,
654        out_currency: v2_forward(h),
655        in_currency: if h.zfo {
656            h.token0_address
657        } else {
658            h.token1_address
659        },
660        out_dest: OutDest::Executor,
661        repay: Repay::SelfRefund,
662        pool_address: h.pool_address,
663        pool_id_hex: None,
664        terminal_form: None,
665        repay_mechanism: None,
666        seed_delivery: None,
667        currency0_address: h.token0_address,
668        currency1_address: h.token1_address,
669    }
670}
671
672/// A V4 hop's facts (shared by the V4-crossing shape derivers).
673pub(crate) fn v4_hop_facts(h: &V4HopInfo) -> HopFacts {
674    let (fwd, inv) = v4_hop_currencies(h);
675    HopFacts {
676        prot: Prot::V4,
677        zfo: h.zfo,
678        swap_fee: u16::try_from(h.fee).unwrap_or(0),
679        tick_spacing: i16::try_from(h.tick_spacing).unwrap_or(0),
680        out_currency: fwd,
681        in_currency: inv,
682        out_dest: OutDest::Executor,
683        repay: Repay::Offstream,
684        pool_address: h.pool_manager_address,
685        pool_id_hex: Some(h.pool_id_hex.clone()),
686        terminal_form: None,
687        repay_mechanism: None,
688        seed_delivery: None,
689        currency0_address: h.currency0_address,
690        currency1_address: h.currency1_address,
691    }
692}
693
694/// `v4_hop_facts` with `repay: Repay::NetZero` (the characteristic tag of the
695/// 2-hop V4-crossing families v3v4/v2v4/v4v4/v4v3/v4v2).
696pub(crate) fn v4_hop_facts_netzero(h: &V4HopInfo) -> HopFacts {
697    let mut f = v4_hop_facts(h);
698    f.repay = Repay::NetZero;
699    f
700}
701
702/// Map a [`HopInfo`] to its [`HopFacts`] using the default per-protocol
703/// mapping: V2→[`v2_hop_facts`], V3→[`v3_hop_facts`], V4→[`v4_hop_facts_netzero`].
704/// Used by [`facts_for`] for families without per-position overrides.
705fn hop_facts(h: &HopInfo) -> HopFacts {
706    match h {
707        HopInfo::V2(a) => v2_hop_facts(a),
708        HopInfo::V3(a) => v3_hop_facts(a),
709        HopInfo::V4(a) => v4_hop_facts_netzero(a),
710    }
711}
712
713/// Whether the 3-tuple key `(Option<Prot>, Option<Prot>, Option<Prot>)`
714/// corresponds to a recognized family shape. Membership is **key-based and
715/// len-agnostic** — a `len ≥ 4` path whose first 3 prots match a 3-hop arm
716/// still yields `true` (preserving the `family_axis_support` presence check:
717/// the axis surface is declared even though the facts dispatcher may decline
718/// on arity). This mirrors the old `build_for_walk` table: every listed arm
719/// has `Some` in slots 1–2.
720pub(crate) fn recognized_key(key: (Option<Prot>, Option<Prot>, Option<Prot>)) -> bool {
721    matches!(key, (Some(_), Some(_), _))
722}
723
724/// The protocol of a hop. (Local mirror of `grammar_shape::prot_of`'s
725/// single-hop form, kept here so `facts_for` is self-contained.)
726fn hop_prot(h: &HopInfo) -> Prot {
727    match h {
728        HopInfo::V2(_) => Prot::V2,
729        HopInfo::V3(_) => Prot::V3,
730        HopInfo::V4(_) => Prot::V4,
731    }
732}
733
734/// The single facts dispatcher (ADR-031 D6). Routes a path to its hop facts:
735///
736/// - All-V2 (≥2 hops) → [`facts_of_all_v2`] (the funding-branched any-N family).
737/// - The five per-position override families (`v3v4v3`, `v2v3`, `v3v3`, `v3v2`)
738///   → their explicit facts fn.
739/// - All other 2-hop and 3-hop families → per-hop [`hop_facts`] (the default
740///   mapping: V4 hops tagged `Repay::NetZero`).
741/// - `len < 2` or `len > 3` (non-all-V2) → `None` (no producer).
742pub(crate) fn facts_for(path: &PathInfo, inputs: &ComposerInputs<'_>) -> Option<Vec<HopFacts>> {
743    let n = path.hops.len();
744    let prots: Vec<Prot> = path.hops.iter().map(hop_prot).collect();
745    if n >= 2 && prots.iter().all(|p| *p == Prot::V2) {
746        return facts_of_all_v2(path, inputs);
747    }
748    // The terminal hop's terminal_form is the axis the merged v3v4[v2|v4] arm
749    // routes on: a trailing V2 swap hands output to SELF directly (DirectHandoff),
750    // a trailing V4 swap settles inside the enclosing V4Unlock (UnlockInternal).
751    // Every other position carries None.
752    let mut facts = match prots.as_slice() {
753        [Prot::V3, Prot::V4, Prot::V3] => facts_of_v3v4v3(path, inputs)?,
754        [Prot::V2, Prot::V3] => facts_of_v2v3(path, inputs)?,
755        [Prot::V3, Prot::V3] => facts_of_v3v3(path, inputs)?,
756        [Prot::V3, Prot::V2] => facts_of_v3v2(path, inputs)?,
757        _ if (2..=3).contains(&n) => path.hops.iter().map(hop_facts).collect(),
758        _ => return None,
759    };
760    if prots.len() == 3 && prots[0] == Prot::V3 && prots[1] == Prot::V4 {
761        let form = match prots[2] {
762            Prot::V2 => Some(TerminalForm::DirectHandoff),
763            Prot::V4 => Some(TerminalForm::UnlockInternal),
764            Prot::V3 => None, // v3v4v3 stays with the residual tag partition
765        };
766        if let Some(form) = form {
767            facts[2].terminal_form = Some(form);
768        }
769    }
770    // ── T6c new-fact overrides (scoped like terminal_form: only the two
771    // group-C holdout families carry Some; every other hop stays None).
772    //
773    // v3v2v4: the V2 flash (hop1) draws its repay at borrow
774    // (`auto_repay=true`) — the sole forward-nested arm. Without this timing
775    // sub-fact the walker cannot tell the V2 flash here (forward, seeder
776    // outer) from the V2 flash in v2v4v2 (reverse, in-callback repay);
777    // both carry `Repay::SelfRefund`.
778    if prots == [Prot::V3, Prot::V2, Prot::V4] {
779        facts[1].repay_mechanism = Some(RepayMechanism::AutoFromExecutor);
780    }
781    // v2v3v4: the optimal-WETH prefund to the leading V2 pool (hop0) is
782    // emitted as a `V4TakeCompact` *inside* the V4Unlock (a V4-managed WETH
783    // delta), plus a matching profit-take to SELF — not the plain
784    // `Erc20Transfer` every other V2-led family uses.
785    if prots == [Prot::V2, Prot::V3, Prot::V4] {
786        facts[0].seed_delivery = Some(SeedDelivery::V4TakeCompact);
787    }
788    Some(facts)
789}
790
791/// The single pipeline entry (ADR-031 D6): `facts_for` → `derive_plan` →
792/// `enc_preamble`. Returns `(preamble, plan, address_table)` or `None` on a
793/// routine decline. The `LedgerValidator` gate (build → `plan_to_ledger_ops`
794/// → `LedgerValidator::validate_full` → `plan_to_bytes`) stays in
795/// `grammar_shape::derive_shape_detailed`.
796#[must_use]
797pub fn build_walk(
798    path: &PathInfo,
799    inputs: &ComposerInputs<'_>,
800) -> Option<(Vec<u8>, Plan, AddressTable)> {
801    let facts = facts_for(path, inputs)?;
802    let (plan, at) = derive_plan(&facts, inputs)?;
803    let preamble = crate::encoders::enc_preamble(&at);
804    Some((preamble, plan, at))
805}
806
807/// The enclosure-deriving walker (ADR-031 D3/D6).
808///
809/// Reads an arbitrary-length hop sequence's [`HopFacts`] and computes the
810/// nesting (which `FlashSwap`/`V4Unlock` wraps which, and the repayment order)
811/// per shape module under [`shapes`] — a `(len, repay-sequence)` partition,
812/// with a genuine `Repay`/`OutDest`-tag partition for the single-V4-middle
813/// residual. See the ADR-031 record correction (2026-08): ordering defects are
814/// caught by the `LedgerValidator` + revm matrix, not unrepresentable by
815/// construction.
816#[must_use]
817pub(crate) fn derive_plan(
818    facts: &[HopFacts],
819    inputs: &ComposerInputs<'_>,
820) -> Option<(Plan, AddressTable)> {
821    // ── Shape dispatch (D3/D6 — the enclosure shape is read from the facts).
822    // Each enclosure block lives in `grammar_walker/shapes/*.rs` (one module
823    // per shape); this fn is a pure (len, repay-sequence) gate dispatcher.
824    if facts.len() >= 2
825        && facts
826            .iter()
827            .all(|f| f.repay != Repay::NetZero && f.prot == Prot::V2)
828    {
829        return shapes::all_v2_chain::derive(facts, inputs);
830    }
831    if facts.len() == 2 && facts[0].repay == Repay::SelfRefund && facts[1].repay == Repay::NetZero {
832        return shapes::two_hop_seed_v4::derive(facts, inputs);
833    }
834    if facts.len() == 2 && facts[0].repay == Repay::NetZero {
835        return shapes::two_hop_v4_led::derive(facts, inputs);
836    }
837    if facts.len() == 3 && !facts.iter().any(|f| f.repay == Repay::Offstream) {
838        return shapes::three_hop::derive(facts, inputs);
839    }
840    if facts.iter().all(|f| f.repay != Repay::NetZero) && facts.len() == 2 {
841        return shapes::two_hop_uniswap_only::derive(facts, inputs);
842    }
843    shapes::tag_residual::derive(facts, inputs)
844}
845
846#[cfg(test)]
847mod tests {
848    #![expect(clippy::cast_possible_truncation, clippy::panic)]
849
850    use super::*;
851    use crate::composers::{
852        ComposerInputs, EncodeOptions, PathInfo, V2HopInfo, V3HopInfo, V4HopInfo,
853    };
854    use alloy::primitives::{address, Address};
855
856    const WETH: Address = address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2");
857    const USDC: Address = address!("A0b86991c6218b36c1D19D4a2e9Eb0cE3606eB48");
858    const WBTC: Address = address!("2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599");
859    const PM: Address = address!("000000000004444c5dc75cB358380D2e3dE08A90");
860    const EXEC: Address = address!("DeAd0000000000000000000000000000000000Be");
861    const OPTIMAL: u128 = 1_000_000_000_000_000_000;
862    static OUTS: [u128; 3] = [1_000_000_000_000_000_000; 3];
863    static CONSUMED: [u128; 3] = [999_999_999_999_999_999; 3];
864
865    fn combo_hops(prots: &[Prot]) -> Vec<HopInfo> {
866        (0..prots.len())
867            .map(|i| {
868                let in_t = match i % 3 {
869                    0 => WETH,
870                    1 => USDC,
871                    _ => WBTC,
872                };
873                let out_t = match (i + 1) % 3 {
874                    0 => WETH,
875                    1 => USDC,
876                    _ => WBTC,
877                };
878                match prots[i] {
879                    Prot::V2 => HopInfo::V2(V2HopInfo {
880                        pool_address: Address::from([0xA0 + i as u8; 20]),
881                        token0_address: in_t,
882                        token1_address: out_t,
883                        fee: 30,
884                        zfo: true,
885                    }),
886                    Prot::V3 => HopInfo::V3(V3HopInfo {
887                        pool_address: Address::from([0xB0 + i as u8; 20]),
888                        token0_address: in_t,
889                        token1_address: out_t,
890                        fee: 3000,
891                        zfo: true,
892                    }),
893                    Prot::V4 => HopInfo::V4(V4HopInfo {
894                        pool_manager_address: PM,
895                        pool_id_hex: format!("0x{i:02x}"),
896                        currency0_address: in_t,
897                        currency1_address: out_t,
898                        fee: 500,
899                        tick_spacing: 10,
900                        hook_address: Address::ZERO,
901                        zfo: true,
902                    }),
903                }
904            })
905            .collect()
906    }
907
908    fn family_name(prots: &[Prot]) -> String {
909        prots
910            .iter()
911            .map(|p| match p {
912                Prot::V2 => "v2",
913                Prot::V3 => "v3",
914                Prot::V4 => "v4",
915            })
916            .collect()
917    }
918
919    /// Every supported family's hop facts tag its V4 hops `Repay::NetZero` —
920    /// the tag the residual tag-driven partition genuinely routes on. Facts
921    /// are fetched through `facts_for`, the same dispatcher the production
922    /// path uses (the single facts route).
923    #[test]
924    fn d6_enclosure_derived_from_facts() {
925        let fams = [Prot::V2, Prot::V3, Prot::V4];
926        let mut netzero_missing: Vec<String> = Vec::new();
927
928        for n in [2usize, 3] {
929            for fidx in 0..fams.len().pow(n as u32) {
930                let prots: Vec<Prot> = (0..n)
931                    .map(|i| fams[(fidx / fams.len().pow(i as u32)) % fams.len()])
932                    .collect();
933                // (v2,v2) 2-hop and (v2,v2,v2) both resolve to all_v2.
934                if prots == vec![Prot::V2, Prot::V2] {
935                    continue;
936                }
937                let name = family_name(&prots);
938                let path = PathInfo::new(combo_hops(&prots));
939                let inputs = ComposerInputs {
940                    executor_address: EXEC,
941                    pool_manager_address: PM,
942                    weth_address: WETH,
943                    optimal_input: OPTIMAL,
944                    hop_outputs: &OUTS[..n],
945                    consumed_inputs: &CONSUMED[..n],
946                    opts: EncodeOptions::default(),
947                };
948                let Some(tags) = facts_for(&path, &inputs)
949                    .map(|fs| fs.iter().map(|f| f.repay).collect::<Vec<_>>())
950                else {
951                    continue;
952                };
953
954                let has_v4 = prots.contains(&Prot::V4);
955                let v4_has_netzero = prots
956                    .iter()
957                    .zip(tags.iter())
958                    .any(|(p, r)| *p == Prot::V4 && *r == Repay::NetZero);
959                if has_v4 && !v4_has_netzero {
960                    let tag_strs: Vec<String> = tags.iter().map(|r| format!("{r:?}")).collect();
961                    netzero_missing.push(format!("{name} (tags=[{}])", tag_strs.join(", ")));
962                }
963            }
964        }
965
966        assert!(
967            netzero_missing.is_empty(),
968            "D6 violation — families whose V4 hops lack Repay::NetZero (the tag \
969             the residual tag partition routes on):\n  {}",
970            netzero_missing.join("\n  ")
971        );
972    }
973    /// T5: the terminal-form axis — one is-terminal-only field on
974    /// `HopFacts`, driving the merge of the v3v4v2/v3v4v4 pair behind a
975    /// single three_hop arm body. Only the terminal hop carries Some; non-
976    /// terminal positions carry None.
977    #[test]
978    fn terminal_form_routes_the_v3v4_pair() {
979        let v2 = combo_hops(&[Prot::V3, Prot::V4, Prot::V2]);
980        let v4 = combo_hops(&[Prot::V3, Prot::V4, Prot::V4]);
981        let inputs = ComposerInputs {
982            executor_address: EXEC,
983            pool_manager_address: PM,
984            weth_address: WETH,
985            optimal_input: OPTIMAL,
986            hop_outputs: &OUTS,
987            consumed_inputs: &CONSUMED,
988            opts: EncodeOptions::default(),
989        };
990        for (hops, expect) in [(v2, "DirectHandoff"), (v4, "UnlockInternal")] {
991            let Some(facts) = facts_for(&PathInfo::new(hops), &inputs) else {
992                panic!("facts exist");
993            };
994            assert_eq!(facts.len(), 3);
995            assert!(facts[0].terminal_form.is_none());
996            assert!(facts[1].terminal_form.is_none());
997            let Some(tf) = facts[2].terminal_form else {
998                panic!("terminal_form set on terminal");
999            };
1000            let kind = format!("{tf:?}");
1001            assert!(kind.contains(expect), "got {kind}, want {expect}");
1002        }
1003    }
1004}