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}