Skip to main content

degenbot_executor/
grammar_plan.rs

1//! The Plan walker — the **deep, stable** half of the grammar (`grammar_shape.rs`
2//! split, ERP6ES / candidate 2 of `architecture-review-1786663110.html`).
3//!
4//! A 2/3-hop family's stream is authored as an execution-ordered,
5//! callback-nested [`Plan`] of [`PlanStep`]s by the builders in
6//! [`crate::grammar_shape`]. This module is the **single representation** both
7//! consumers derive from:
8//! * the **encoder** — [`plan_to_bytes`] emits the command stream;
9//! * the **validator** — [`plan_to_ledger_ops`] projects the execution trace,
10//!   gated by [`crate::grammar_ledger::LedgerValidator`] (ADR-029 D5: the
11//!   generic validator proving ordering from declarative facts).
12//!
13//! The `PlanStep` vocabulary + the two walkers are the **deep, stable**
14//! interface (`Plan → LedgerOp`, `Plan → bytes`) the grammar exists to serve —
15//! ~770 lines, churned rarely. The wide, churning surface — the `build_walk`
16//! pipeline + the `derive_shape` dispatch — lives in
17//! [`crate::grammar_shape`], so a family addition stops churning this file
18//! and its tests.
19//!
20//! One representation, no drift, no reordering, no per-family trace
21//! duplication. [`crate::grammar_shape::derive_shape`] dispatches every
22//! well-formed family through `build_walk` + the `LedgerValidator` gate,
23//! returning `None` on decline or gate rejection.
24//!
25//! ---
26//! **Parity sources of truth:** the revm runtime matrix (`degenbot-simulation`
27//! full_matrix, exact delta); the primitive wire-format layer
28//! (`tests/encoders_parity.rs`); the native bridge byte-golden
29//! (`tests/native_eth_3hop_bridge.rs`).
30use alloy::primitives::{Address, U256};
31
32use crate::composers::{V2HopInfo, V3HopInfo, NATIVE_CURRENCY_ADDRESS};
33use crate::encoders::{self, AddressTable, SENTINEL_PM, SENTINEL_SELF};
34use crate::grammar_ledger::{LedgerOp, SwapRecipient};
35
36/// A hop-protocol family member.
37pub use crate::grammar_ledger::Prot;
38
39// The 2/3-hop axis types (FundingSource + ProfitCapture + Bribe + ShapeClass)
40// live in grammar_ledger (ADR-029 D1, WE45KC unification): the open-set enum is
41// the single source of truth, re-exported here for the builders + consumers.
42pub use crate::grammar_ledger::{
43    Axis, AxisSupport, Bribe, FundingSource, ProfitCapture, ShapeClass,
44};
45
46// The retained terminal-hop fixture (`emit_terminal_hop`) takes `ComposerInputs`
47// and matches on `HopInfo`; scoped to test builds so the lib build has no
48// unused-import warning.
49#[cfg(test)]
50use crate::composers::{ComposerInputs, HopInfo};
51
52pub(crate) fn v2_forward(h: &V2HopInfo) -> Address {
53    if h.zfo {
54        h.token1_address
55    } else {
56        h.token0_address
57    }
58}
59pub(crate) fn v3_forward(h: &V3HopInfo) -> Address {
60    if h.zfo {
61        h.token1_address
62    } else {
63        h.token0_address
64    }
65}
66pub(crate) fn v3_input(h: &V3HopInfo) -> Address {
67    if h.zfo {
68        h.token0_address
69    } else {
70        h.token1_address
71    }
72}
73
74/// Per-protocol encoder selection for the **terminal** hop (D4 mechanics half).
75///
76/// `pre_grant_to` is the address-table index already credited with the hop's
77/// input (a prior `V4_TAKE_COMPACT`/`ERC20_TRANSFER` into the pair). A terminal
78/// V2 always swaps via `V2_SWAP_CALC` from that pre-grant (credit-before-debit
79/// on the pair-handoff ledger — the `2PT5HH` / `path-182449` rule); a terminal
80/// V3 is a `V3_SWAP_COMPACT` flash whose input comes from the coupled ledger.
81///
82/// RVNIPD: after the emitter deletion this helper survives only as the
83/// fixture for the terminal-V2 `V2_SWAP_CALC`-not-`V2_SWAP_COMPACT` rule test.
84#[cfg(test)]
85fn emit_terminal_hop(
86    at: &mut AddressTable,
87    h: &HopInfo,
88    inputs: &ComposerInputs<'_>,
89    swap_in: u128,
90    pre_grant_to: u8,
91    out: &mut Vec<u8>,
92) -> Option<()> {
93    match h {
94        HopInfo::V2(x) => {
95            // Terminal-V2 pre-fund rule: swap from whatever the feeder actually
96            // delivered to the pair (V2_SWAP_CALC), never an exact-out
97            // `V2_SWAP_COMPACT` (over-draws 1 wei → `UniswapV2: K`).
98            let _ = (x.pool_address, pre_grant_to, inputs);
99            out.extend_from_slice(&encoders::enc_v2_swap_calc(
100                at.add(x.pool_address).ok()?,
101                x.zfo,
102                SENTINEL_SELF,
103                x.fee,
104            ));
105        }
106        HopInfo::V3(x) => {
107            out.extend_from_slice(
108                &encoders::enc_v3_swap_compact(
109                    at.add(x.pool_address).ok()?,
110                    x.zfo,
111                    swap_in,
112                    SENTINEL_SELF,
113                    &[],
114                )
115                .ok()?,
116            );
117        }
118        HopInfo::V4(_) => unreachable!("V4 outside the spike"),
119    }
120    Some(())
121}
122// ═══════════════════════════════════════════════════════════════════════════
123// Plan tree — the primary grammar artifact (ADR-029 D4, mechanism (iii), `BP7KIR`).
124// A family's ledger decisions authored as an execution-ordered, callback-nested
125// tree. Two consumers derive from the SAME Plan: the encoder (`Plan→Vec<u8>`)
126// and the validator (`Plan→LedgerOp`, depth-first = execution order). One
127// representation, no drift, no reordering, no per-family trace duplication.
128//
129// Checkpoint 1: Step set scoped to the `v2_v3` (InPathFlash) family
130// `FlashSwap` (V2/V3, carries its callback subtree) + `Erc20Transfer`. The
131// remaining Step variants (V4Unlock, V4Swap, V4Take, V4Sync/Settle, V2SwapCalc,
132// WethDeposit/Withdraw, V4Batch/Mint, …) land incrementally as families fold.
133// ═══════════════════════════════════════════════════════════════════════════
134
135/// A single node of the execution-ordered, callback-nested command Plan. The
136/// nesting IS execution order: a `FlashSwap`'s `callback` fires when the swap
137/// runs (depth-first); a `V4Unlock`'s `inner` runs in the unlock callback.
138///
139/// Each leaf carries BOTH the resolved address-table index (for the byte
140/// encoder) and the currency/pool address (for the `LedgerOp` projection) —
141/// Checkpoint 1 keeps this minimal; a later refactor may separate
142/// address-collection from emission if it clarifies (see `BP7KIR` body).
143#[derive(Clone, Debug, PartialEq)]
144pub enum PlanStep {
145    /// A V2 or V3 `*_SWAP_COMPACT` flash — the pool credits `out_currency` to
146    /// the executor and is owed `in_currency` within `callback` (the bytes the
147    /// flash's callback payload carries, fired when the swap runs).
148    FlashSwap {
149        pool_idx: u8,
150        pool_addr: Address,
151        protocol: Prot,
152        zfo: bool,
153        fee: u16,
154        out_currency: Address,
155        out_amount: u128,
156        in_currency: Address,
157        in_amount: u128,
158        recipient_idx: u8,
159        /// The recipient pool's address when the flash's OUTPUT directly seeds
160        /// a pool (e.g. a V3 flash paying the terminal V2 — `v4_v3_v2`).
161        /// `None` for a →SELF output. Bytes encode `recipient_idx`; this is
162        /// ledger-only (the output seeds the pool's pair-handoff instead of
163        /// crediting the executor).
164        recipient_pool_addr: Option<Address>,
165        /// Whether a pool-recipient flash output REPAYS that pool's flash debt
166        /// (a V3→V3 repayment — `v4_v3_v3`) vs seeds it (a V3 flash feeding the
167        /// terminal V2 — `v4_v3_v2`).
168        recipient_pool_repays: bool,
169        /// Whether the cmd_executor auto-pays `in_currency` from the executor's
170        /// `E[]` balance at callback-end (V2/V3 `*_SWAP_COMPACT` with an empty /
171        /// no-repay callback). When true, the projection emits a trailing
172        /// flash-repayment `Erc20Transfer` after the callback (the auto-pay).
173        auto_repay: bool,
174        callback: Plan,
175    },
176    /// An `ERC20_TRANSFER(token→recipient, amount)` from the executor. Doubles
177    /// as flash-repayment and pair-seed by recipient role (DS4OQD finding 5):
178    /// when the recipient is a V2 pair being pre-funded, `seeds_pool` carries
179    /// that pair's address so the projection also credits the pair-handoff
180    /// ledger (a following `V2SwapCalc` consumes it).
181    Erc20Transfer {
182        token_idx: u8,
183        token_addr: Address,
184        recipient_idx: u8,
185        amount: u128,
186        seeds_pool: Option<Address>,
187        /// When `Some(pool)`, this transfer repays that flash pool (debited
188        /// `min(amount, owed)` so explicit + auto-pay compose without
189        /// over-debiting).
190        repays_flash: Option<Address>,
191    },
192    /// A `V2_SWAP_CALC(pool, zfo, recipient, fee)` — the terminal-V2 pre-fund
193    /// rule (`2PT5HH`): swap from whatever the feeder delivered to the pair,
194    /// never an exact-out `V2_SWAP_COMPACT` (over-drains 1 wei → `UniswapV2: K`).
195    /// Consumes the pair-handoff credit seeded by a prior `Erc20Transfer`.
196    V2SwapCalc {
197        pool_idx: u8,
198        pool_addr: Address,
199        zfo: bool,
200        recipient_idx: u8,
201        fee: u16,
202        /// The swap's output currency + amount credited to the executor (the
203        /// profit / downstream repayment source). Option (B): swaps credit
204        /// their output so the executor ledger fully accounts.
205        out_currency: Address,
206        out_amount: u128,
207        /// The recipient pool's address when the calc pays a **mid** pool
208        /// (ledger-only — the bytes encode `recipient_idx`). The projection
209        /// routes the output: `Some(pool)` seeds that pool's pair-handoff (no
210        /// executor credit); `None` + recipient = SELF keeps the executor
211        /// credit; `None` + recipient = PM pays into the PM. 2-hop families
212        /// always pass `None` (SELF recipients) — the byte-identical case.
213        recipient_pool_addr: Option<Address>,
214        /// Whether a pool recipient is a **V3 flash repayment** (saturating
215        /// `flash_debt` reduction, `SwapRecipient::PoolRepay`) vs a V2 pre-fund
216        /// seed (`SwapRecipient::Pool`). Together with `recipient_pool_addr`.
217        recipient_repays: bool,
218    },
219    /// An exact-out `V2_SWAP_DIRECT(pool, zfo, out_amount, recipient)` — the
220    /// V2 handoff that pays a specific `out_amount` to `recipient` (a next
221    /// pool or the executor). Distinct from [`PlanStep::V2SwapCalc`]
222    /// (exact-in) and [`PlanStep::FlashSwap`] (credit-the-executor). Ledger:
223    /// consumes the donor pool's seeded `H[pool]`; the output goes to the
224    /// recipient — SELF credits the executor, a pool seeds that pool
225    /// (`recipient_pool_addr`, ledger-only).
226    V2SwapDirect {
227        pool_idx: u8,
228        pool_addr: Address,
229        zfo: bool,
230        out_amount: u128,
231        recipient_idx: u8,
232        /// The exact-out currency (the recipient pool's input / the executor
233        /// profit token). Credited to the executor when the recipient is SELF;
234        /// the seeded-currency for a pool recipient.
235        out_currency: Address,
236        /// The recipient pool's address when the direct pays a mid pool.
237        recipient_pool_addr: Option<Address>,
238        /// Whether a pool recipient is a V3 flash repayment (`PoolRepay`) vs a
239        /// V2 pre-fund seed (`Pool`).
240        recipient_repays: bool,
241    },
242    /// A self-fund seed (ADR-029 FundingSource::SelfFund) — the executor holds
243    /// `amount` of `currency` as entry capital before the stream. Not a command;
244    /// a stream precondition the validator credits so SelfFund families' flash
245    /// repayments validate. The encoder emits nothing for it.
246    SelfFund { currency: Address, amount: u128 },
247    // ── V4 (BP7KIR Increment 3): the PoolManager container + delta ops. ──
248    /// A `V4_UNLOCK(inner)` — the PM callback scope. `inner` runs inside the
249    /// unlock; at its end the master V4 invariant fires: every touched PM delta
250    /// must net to zero (`V4UnlockEnd`).
251    V4Unlock { inner: Plan, pool_manager_idx: u8 },
252    /// A `V4_SWAP_COMPACT(c0, c1, fee, ts, hooks, zfo, amount)` — creates
253    /// `PM[in]` debt and `PM[out]` credit (both legs modeled so net-zero is
254    /// checkable).
255    V4Swap {
256        c0_idx: u8,
257        c1_idx: u8,
258        fee: u16,
259        tick_spacing: i16,
260        hooks_idx: u8,
261        zfo: bool,
262        amount: u128,
263        in_currency: Address,
264        in_amount: u128,
265        out_currency: Address,
266        out_amount: u128,
267    },
268    /// `V4_TAKE_DELTA(cur→rcp)` — takes the entire positive `PM[cur]` delta to
269    /// `rcp` (the profit capture; debits PM credit). When the recipient is a
270    /// V2 pool (`seeds_pool`), the taken credit seeds that pool's pair-handoff
271    /// (the 2PT5HH terminal-V2 rule across the V4 boundary — `v3_v4_v2`).
272    V4TakeDelta {
273        currency_idx: u8,
274        currency_addr: Address,
275        recipient_idx: u8,
276        /// When the recipient is a V2 pair, the taken credit seeds it (PM→pool).
277        seeds_pool: Option<Address>,
278    },
279    /// `V4_SETTLE_ALL` — auto-settle every touched PM currency to 0.
280    V4SettleAll,
281    /// `V4_TAKE_COMPACT(cur→rcp, amount)` — take a specific `amount` of `cur`'s
282    /// PM credit to `rcp` (the boundary-take: V4 output leaves the PM to feed
283    /// a V2/V3 hop or to capture profit). Debits PM (D0 credit-before-debit).
284    /// The recipient role determines the **second** ledger effect (the
285    /// cross-ledger move, mirroring `Erc20Transfer`'s seeds_pool/repays_flash):
286    /// `recipient_idx == SENTINEL_SELF` credits the executor `Erc20[cur]`
287    /// (the token arrives at the executor); `seeds_pool = Some(pool)` credits
288    /// `PairHandoff[pool]` (the token seeds a V2 pair directly, PM→pool, never
289    /// touching executor Erc20).
290    V4TakeCompact {
291        currency_idx: u8,
292        currency_addr: Address,
293        recipient_idx: u8,
294        amount: u128,
295        /// When `Some(pool)`, the take's recipient is a V2 pair being
296        /// pre-funded directly (PM→pool) — credit `PairHandoff[pool]` so a
297        /// following `V2SwapCalc` sees its seed. `None` for a →SELF take.
298        seeds_pool: Option<Address>,
299        /// When `Some(pool)`, the take is a **flash repayment** to that pool
300        /// (a V3 flash repaid directly from the PM — e.g. the `v4_v4_v3` tail).
301        /// The take debits PM and saturating-repays the pool's flash debt (no
302        /// executor Erc20 debit). `None` for a take/seed.
303        repays_flash: Option<Address>,
304    },
305    /// `V4_SETTLE_DELTA(cur)` — auto-settle one currency's PM delta to 0.
306    V4SettleDelta {
307        currency_idx: u8,
308        currency_addr: Address,
309    },
310    /// `V4_SYNC(cur)` — sync the PM's internal balance for `cur` (a balance-sync
311    /// primitive, **delta-neutral**: it carries no PM-delta effect; the actual
312    /// delta application comes from a following `V4Settle`). Used in the
313    /// boundary-seed pattern (executor pays `cur` into the PM via an
314    /// `Erc20Transfer(cur→PM)` then `V4Settle`) to settle a V4 input debt.
315    V4Sync {
316        currency_idx: u8,
317        currency_addr: Address,
318    },
319    /// `V4_SETTLE` — the executor pays `amount` of `currency` into the PM,
320    /// cancelling debt (`PM[currency] += amount`; the executor's `Erc20`
321    /// debit happens at the preceding `Erc20Transfer(cur→PM)`). Used after a
322    /// `V4Sync` + `Erc20Transfer` boundary-seed. The byte form is the no-arg
323    /// `V4_SETTLE`; the IR carries `currency`/`amount` explicitly so the
324    /// validator knows which delta to apply.
325    V4Settle {
326        currency_addr: Address,
327        amount: u128,
328    },
329    /// `NATIVE_TRANSFER(amount)` — the executor→PM native pay-in leg of a
330    /// native settle. Ledger-only (encodes to nothing, like
331    /// `SelfFund`): on-chain the native flows as `msg.value` on the
332    /// `V4_SETTLE*` call, so there is no separate byte instruction. Modeled
333    /// explicitly so the executor's native debit is a separate observable op
334    /// (a missing settle half is caught by PM-net-zero, not absorbed).
335    NativeTransfer { amount: u128 },
336    /// `WETH_WITHDRAW(amount)` — unwrap WETH to native (the source of native
337    /// for a `NativeTransfer` PM pay-in, or to seed a native V4 input).
338    WethWithdraw {
339        weth_idx: u8,
340        weth_addr: Address,
341        amount: u128,
342    },
343    /// `WETH_DEPOSIT(amount)` — wrap native to WETH (the native came from a
344    /// `V4TakeCompact(native→SELF)`).
345    WethDeposit {
346        weth_idx: u8,
347        weth_addr: Address,
348        amount: u128,
349    },
350    /// `V4_BATCH` (0x42) / `V4_BATCH_OPEN_WETH` (0x43) — a bundled PM extcall
351    /// of up to 8 swaps (`encoders::enc_v4_batch` /
352    /// `encoders::enc_v4_batch_open_weth`). Ledger-equivalent to the constituent
353    /// `V4Swap`s: each entry applies the same `PM[in]` debt / `PM[out]`
354    /// credit. **Asymmetry vs a plain `V4Swap` sequence:** the 0x42 contract
355    /// auto-settles any positive native ETH and WETH delta at the batch's end
356    /// (an implicit `V4_TAKE_DELTA(→SELF)` for those two currencies). For the
357    /// WETH-only slice (the executor's proven path) the derive therefore omits
358    /// the terminal `V4TakeDelta` when `use_v4_batch` is set — the batch already
359    /// captured the WETH profit. The Plan mirrors this: the per-entry ledger
360    /// deltas leave a positive `PM[weth]` that the trailing `V4SettleAll`
361    /// zeroes (the gate's master invariant fires at `V4UnlockEnd` — the profit
362    /// capture is modelled by the contract, not by a `Take` op here).
363    ///
364    /// `open_weth`: `false` = 0x42 (full tail-settle); `true` = 0x43 — the
365    /// WETH tail-settle is SKIPPED, so the positive `PM[weth]` delta is left
366    /// OPEN for a trailing `V4Mint` (ERC6909 capture, TGUZCT/SW42JA). The 0x43
367    /// projection additionally emits the `LedgerOp::OpenWethPairing` gate op,
368    /// which requires a WETH `Mint` before `V4UnlockEnd`.
369    V4Batch {
370        entries: Vec<V4BatchSwap>,
371        open_weth: bool,
372    },
373    /// `V4_MINT_COMPACT(cur→rcp, amount)` — convert a positive `PM[cur]`
374    /// delta into an ERC6909 claim for `rcp` (BP7KIR `erc6909_profit` opt).
375    /// Ledger-equivalent to [`PlanStep::V4TakeDelta`]: debits `PM[cur]` by
376    /// `amount` (requires credit-before-debit, `D0`). The asset stays inside
377    /// the PM as a claim rather than a physical transfer — distinct from
378    /// `V4TakeDelta` on-chain, identical for the gate's safety invariants.
379    V4Mint {
380        currency_idx: u8,
381        currency_addr: Address,
382        recipient_idx: u8,
383        amount: u128,
384    },
385}
386
387/// One entry of a [`PlanStep::V4Batch`] — the codec fields (consumed by
388/// `encoders::enc_v4_batch` via [`V4BatchEntry`][encoders::V4BatchEntry])
389/// together with the resolved currency/amount legs (consumed by a per-entry
390/// `LedgerOp::V4Swap` projection). Carrying both keeps byte and ledger
391/// projection derivable from the one Plan tree (ADR-029 D4 (iii)).
392#[derive(Debug, Clone, Copy, PartialEq, Eq)]
393pub struct V4BatchSwap {
394    /// Currency-0 table index.
395    pub c0_idx: u8,
396    /// Currency-1 table index.
397    pub c1_idx: u8,
398    /// Pool fee (`uint16` view of the `uint24` on-chain key).
399    pub fee: u16,
400    /// Tick spacing (`int16` view).
401    pub tick_spacing: i16,
402    /// Hooks address index (`0xFF` = no hooks).
403    pub hooks_idx: u8,
404    /// `zero_for_one` direction flag.
405    pub zfo: bool,
406    /// Positive `uint96` exact-input amount.
407    pub amount: u128,
408    /// Resolved input currency (for the ledger projection).
409    pub in_currency: Address,
410    /// Resolved input amount (matches `amount` for the standard exact-input
411    /// entry; carried separately so the ledger projection is exact).
412    pub in_amount: u128,
413    /// Resolved output currency.
414    pub out_currency: Address,
415    /// Resolved output amount.
416    pub out_amount: u128,
417}
418
419/// A Plan = an ordered list of steps. Depth-first walk = execution order.
420pub type Plan = Vec<PlanStep>;
421
422/// Project a `Plan` to its `LedgerOp` trace (depth-first; a `FlashSwap`
423/// emits its flash credit/debt term, then recurses into its callback). This is
424/// the validator's input — decoupled from byte layout (ADR-029 D5).
425#[must_use]
426#[expect(clippy::too_many_lines)]
427pub fn plan_to_ledger_ops(plan: &Plan) -> Vec<LedgerOp> {
428    #[expect(clippy::too_many_lines)]
429    fn walk(plan: &Plan, ops: &mut Vec<LedgerOp>) {
430        for step in plan {
431            match step {
432                PlanStep::FlashSwap {
433                    pool_addr,
434                    protocol,
435                    out_currency,
436                    out_amount,
437                    in_currency,
438                    in_amount,
439                    auto_repay,
440                    callback,
441                    recipient_pool_addr,
442                    recipient_pool_repays,
443                    recipient_idx,
444                    ..
445                } => {
446                    // Route the output like a recipient-aware swap: Executor
447                    // (credit), Pool(p) (seed the V2 handoff), PoolRepay(p)
448                    // (repay a V3 flash debt), PoolManager (pay the PM). The
449                    // swap also incurs an `in_currency` flash debt repayable
450                    // within the callback.
451                    let recipient =
452                        match (recipient_pool_addr, recipient_pool_repays, *recipient_idx) {
453                            (Some(p), true, _) => SwapRecipient::PoolRepay(*p),
454                            (Some(p), false, _) => SwapRecipient::Pool(*p),
455                            (None, _, SENTINEL_PM) => SwapRecipient::PoolManager,
456                            _ => SwapRecipient::Executor,
457                        };
458                    let flash = match protocol {
459                        Prot::V2 => LedgerOp::V2Flash {
460                            out_currency: *out_currency,
461                            out_amount: *out_amount,
462                            in_currency: *in_currency,
463                            in_amount: *in_amount,
464                            recipient,
465                        },
466                        Prot::V3 => LedgerOp::V3Flash {
467                            out_currency: *out_currency,
468                            out_amount: *out_amount,
469                            in_currency: *in_currency,
470                            in_amount: *in_amount,
471                            recipient,
472                        },
473                        Prot::V4 => unreachable!("V4 flash is not a FlashSwap (V4 has no flash); V4Unlock lands in a later increment"),
474                    };
475                    ops.push(flash);
476                    walk(callback, ops);
477                    // Auto-pay (empty/no-repay callback): the cmd_executor debits
478                    // `in_currency` from the executor at callback-end. Modeled as
479                    // a flash-repayment transfer (min(amount, owed) so it composes
480                    // with any partial explicit repayment in the callback).
481                    if *auto_repay {
482                        ops.push(LedgerOp::Erc20Transfer {
483                            currency: *in_currency,
484                            amount: *in_amount,
485                            repays_flash: Some(*pool_addr),
486                        });
487                    }
488                }
489                PlanStep::Erc20Transfer {
490                    token_addr,
491                    amount,
492                    seeds_pool,
493                    repays_flash,
494                    ..
495                } => {
496                    ops.push(LedgerOp::Erc20Transfer {
497                        currency: *token_addr,
498                        amount: *amount,
499                        repays_flash: *repays_flash,
500                    });
501                    // DS4OQD finding 5: a transfer TO a V2 pair pre-funds it —
502                    // credit the pair-handoff ledger so a following `V2SwapCalc`
503                    // sees its seed (the terminal-V2 credit-before-debit rule).
504                    if let Some(pool) = seeds_pool {
505                        ops.push(LedgerOp::SeedPair {
506                            pool: *pool,
507                            amount: *amount,
508                        });
509                    }
510                }
511                PlanStep::V2SwapCalc {
512                    pool_addr,
513                    recipient_idx,
514                    out_currency,
515                    out_amount,
516                    recipient_pool_addr,
517                    recipient_repays,
518                    ..
519                } => {
520                    // `V2_SWAP_CALC` consumes the seeded pair-handoff credit and
521                    // routes its computed output by recipient role: the
522                    // recipient pool address wins (a mid pool seeds that
523                    // pool's handoff); otherwise the sentinel dictates — PM
524                    // pays the PM (the following V4Settle/net-zero accounts
525                    // it), SELF credits the executor (the 2-hop terminal case).
526                    let recipient = match (recipient_pool_addr, recipient_repays, *recipient_idx) {
527                        (Some(p), true, _) => SwapRecipient::PoolRepay(*p),
528                        (Some(p), false, _) => SwapRecipient::Pool(*p),
529                        (None, _, SENTINEL_PM) => SwapRecipient::PoolManager,
530                        _ => SwapRecipient::Executor,
531                    };
532                    ops.push(LedgerOp::SwapCalc {
533                        pool: *pool_addr,
534                        amount_in: 0,
535                        out_currency: *out_currency,
536                        out_amount: *out_amount,
537                        recipient,
538                    });
539                }
540                // `V2_SWAP_DIRECT` — exact-out handoff. Ledger-equivalent to a
541                // `V2SwapCalc` with the same recipient routing: consumes the
542                // donor's seed; SELF credits the executor, a mid pool seeds it.
543                PlanStep::V2SwapDirect {
544                    pool_addr,
545                    recipient_idx,
546                    out_currency,
547                    out_amount,
548                    recipient_pool_addr,
549                    recipient_repays,
550                    ..
551                } => {
552                    let recipient = match (recipient_pool_addr, recipient_repays, *recipient_idx) {
553                        (Some(p), true, _) => SwapRecipient::PoolRepay(*p),
554                        (Some(p), false, _) => SwapRecipient::Pool(*p),
555                        (None, _, SENTINEL_PM) => SwapRecipient::PoolManager,
556                        _ => SwapRecipient::Executor,
557                    };
558                    ops.push(LedgerOp::SwapCalc {
559                        pool: *pool_addr,
560                        amount_in: 0,
561                        out_currency: *out_currency,
562                        out_amount: *out_amount,
563                        recipient,
564                    });
565                }
566                PlanStep::SelfFund { currency, amount } => {
567                    ops.push(LedgerOp::SelfFund {
568                        currency: *currency,
569                        amount: *amount,
570                    });
571                }
572                // V4 container: recurse the unlock's inner Plan, then emit
573                // `V4UnlockEnd` (the net-zero check fires there).
574                PlanStep::V4Unlock { inner, .. } => {
575                    walk(inner, ops);
576                    ops.push(LedgerOp::V4UnlockEnd);
577                }
578                PlanStep::V4Swap {
579                    in_currency,
580                    in_amount,
581                    out_currency,
582                    out_amount,
583                    ..
584                } => {
585                    ops.push(LedgerOp::V4Swap {
586                        in_currency: *in_currency,
587                        in_amount: *in_amount,
588                        out_currency: *out_currency,
589                        out_amount: *out_amount,
590                    });
591                }
592                PlanStep::V4TakeDelta {
593                    currency_addr,
594                    recipient_idx,
595                    seeds_pool,
596                    ..
597                } => {
598                    ops.push(LedgerOp::V4TakeDelta {
599                        currency: *currency_addr,
600                        recipient_idx: *recipient_idx,
601                        seeds_pool: *seeds_pool,
602                    });
603                }
604                PlanStep::V4SettleAll => {
605                    ops.push(LedgerOp::V4SettleAll);
606                }
607                PlanStep::V4TakeCompact {
608                    currency_addr,
609                    amount,
610                    recipient_idx,
611                    seeds_pool,
612                    repays_flash,
613                    ..
614                } => {
615                    ops.push(LedgerOp::Take {
616                        currency: *currency_addr,
617                        amount: *amount,
618                        repays_flash: *repays_flash,
619                    });
620                    // Cross-ledger move: when the take's recipient is the
621                    // executor (SELF), the token physically arrives at the
622                    // executor's Erc20 balance — credit it so a downstream
623                    // V2/V3 flash that consumes `cur` (e.g. the V3 auto-repay
624                    // in `v4_v3`) validates. When the recipient is a V2 pair,
625                    // the token seeds the pair directly (PM→pool) — credit
626                    // `PairHandoff[pool]` so a following `V2SwapCalc` sees its
627                    // seed (the 2PT5HH terminal-V2 rule across the PM boundary).
628                    if *recipient_idx == SENTINEL_SELF {
629                        // The token physically arrives at the executor's balance.
630                        // Which ledger depends on the currency: native credits
631                        // `Native` (a later `WethDeposit`/`NativeTransfer`
632                        // consumes it); an ERC-20 credits `Erc20[cur]`.
633                        if *currency_addr == NATIVE_CURRENCY_ADDRESS {
634                            ops.push(LedgerOp::NativeCredit { amount: *amount });
635                        } else {
636                            ops.push(LedgerOp::Erc20Credit {
637                                currency: *currency_addr,
638                                amount: *amount,
639                            });
640                        }
641                    }
642                    if let Some(pool) = seeds_pool {
643                        ops.push(LedgerOp::SeedPair {
644                            pool: *pool,
645                            amount: *amount,
646                        });
647                    }
648                }
649                // V4_SYNC is a balance-sync primitive — delta-neutral, no
650                // ledger effect (the delta application comes from the
651                // following V4Settle). Emitted for byte-parity only.
652                PlanStep::V4Sync { .. } => {}
653                PlanStep::V4Settle {
654                    currency_addr,
655                    amount,
656                } => {
657                    ops.push(LedgerOp::V4Settle {
658                        currency: *currency_addr,
659                        amount: *amount,
660                    });
661                }
662                PlanStep::V4SettleDelta { currency_addr, .. } => {
663                    ops.push(LedgerOp::V4SettleDelta {
664                        currency: *currency_addr,
665                    });
666                }
667                PlanStep::NativeTransfer { amount } => {
668                    ops.push(LedgerOp::NativeTransfer { amount: *amount });
669                }
670                PlanStep::WethWithdraw {
671                    weth_addr, amount, ..
672                } => {
673                    ops.push(LedgerOp::WethWithdraw {
674                        weth: *weth_addr,
675                        amount: *amount,
676                    });
677                }
678                PlanStep::WethDeposit {
679                    weth_addr, amount, ..
680                } => {
681                    ops.push(LedgerOp::WethDeposit {
682                        weth: *weth_addr,
683                        amount: *amount,
684                    });
685                }
686                PlanStep::V4Batch { entries, open_weth } => {
687                    // Each batch entry applies the same `PM[in]` debt / `PM[out]`
688                    // credit as a standalone `V4Swap`. The batch's on-chain
689                    // auto-settle of native+WETH positive deltas is modelled
690                    // downstream (the derive emits no `V4TakeDelta` for the
691                    // WETH slice; the trailing `V4SettleAll` zeroes the
692                    // residual `PM[weth]` — the gate's master invariant).
693                    for e in entries {
694                        ops.push(LedgerOp::V4Swap {
695                            in_currency: e.in_currency,
696                            in_amount: e.in_amount,
697                            out_currency: e.out_currency,
698                            out_amount: e.out_amount,
699                        });
700                    }
701                    if *open_weth {
702                        // TGUZCT/SW42JA: the 0x43 variant leaves the terminal
703                        // (WETH) delta open — arm the pairing gate for the
704                        // trailing `V4_MINT_COMPACT`.
705                        let weth = entries.last().map(|e| e.out_currency).unwrap_or_default();
706                        ops.push(LedgerOp::OpenWethPairing { weth });
707                    }
708                }
709                PlanStep::V4Mint {
710                    currency_addr,
711                    amount,
712                    ..
713                } => {
714                    ops.push(LedgerOp::Mint {
715                        currency: *currency_addr,
716                        amount: *amount,
717                    });
718                }
719            }
720        }
721    }
722    let mut ops = Vec::new();
723    walk(plan, &mut ops);
724    ops
725}
726
727/// Encode a `Plan` to the `execute()` byte stream (depth-first; a `FlashSwap`
728/// wraps its callback's bytes as the swap's callback payload). Mirrors the
729/// proven hand-written emitter's `enc_*` calls — byte-parity with it is the
730/// guard that this Plan-derived encoder reproduces the exact proven bytes.
731#[must_use]
732#[expect(clippy::too_many_lines)]
733pub fn plan_to_bytes(plan: &Plan, at: &AddressTable) -> Vec<u8> {
734    #[expect(clippy::too_many_lines, clippy::expect_used)]
735    fn walk(plan: &Plan, at: &AddressTable, out: &mut Vec<u8>) {
736        // The plan is LedgerValidator-validated before encoding, so the encoder
737        // range checks below are unreachable; the `.expect()`s are deliberate
738        // documentation of that invariant (args are in range by construction).
739        for step in plan {
740            match step {
741                PlanStep::FlashSwap {
742                    pool_idx,
743                    protocol,
744                    zfo,
745                    fee,
746                    out_amount,
747                    in_amount,
748                    recipient_idx,
749                    callback,
750                    ..
751                } => {
752                    let cb = plan_to_bytes(callback, at);
753                    match protocol {
754                        Prot::V2 => out.extend_from_slice(
755                            &encoders::enc_v2_swap_compact(
756                                *pool_idx,
757                                *zfo,
758                                *out_amount,
759                                *recipient_idx,
760                                *fee,
761                                &cb,
762                            )
763                            .expect("V2 swap compact args in range"),
764                        ),
765                        Prot::V3 => out.extend_from_slice(
766                            &encoders::enc_v3_swap_compact(
767                                *pool_idx,
768                                *zfo,
769                                *in_amount,
770                                *recipient_idx,
771                                &cb,
772                            )
773                            .expect("V3 swap compact args in range"),
774                        ),
775                        Prot::V4 => unreachable!("V4 flash is not a FlashSwap"),
776                    }
777                }
778                PlanStep::Erc20Transfer {
779                    token_idx,
780                    recipient_idx,
781                    amount,
782                    ..
783                } => out.extend_from_slice(
784                    &encoders::enc_erc20_transfer(*token_idx, *recipient_idx, *amount)
785                        .expect("ERC20 transfer amount in range"),
786                ),
787                PlanStep::V2SwapCalc {
788                    pool_idx,
789                    zfo,
790                    recipient_idx,
791                    fee,
792                    ..
793                } => out.extend_from_slice(&encoders::enc_v2_swap_calc(
794                    *pool_idx,
795                    *zfo,
796                    *recipient_idx,
797                    *fee,
798                )),
799                // Self-fund and native transfer are stream preconditions /
800                // ledger-only moves, not on-chain commands — no byte.
801                PlanStep::SelfFund { .. } | PlanStep::NativeTransfer { .. } => {}
802                PlanStep::V2SwapDirect {
803                    pool_idx,
804                    zfo,
805                    out_amount,
806                    recipient_idx,
807                    ..
808                } => out.extend_from_slice(
809                    &encoders::enc_v2_swap_direct(*pool_idx, *zfo, *out_amount, *recipient_idx)
810                        .expect("V2 swap direct exact-out in range"),
811                ),
812                // V4 container: encode inner, wrap in V4_UNLOCK.
813                PlanStep::V4Unlock {
814                    inner,
815                    pool_manager_idx: _,
816                } => {
817                    let inner_bytes = plan_to_bytes(inner, at);
818                    out.extend_from_slice(
819                        &encoders::enc_v4_unlock(&inner_bytes)
820                            .expect("V4 unlock forward_data in range"),
821                    );
822                }
823                PlanStep::V4Swap {
824                    c0_idx,
825                    c1_idx,
826                    fee,
827                    tick_spacing,
828                    hooks_idx,
829                    zfo,
830                    amount,
831                    ..
832                } => out.extend_from_slice(
833                    &encoders::enc_v4_swap_compact(
834                        *c0_idx,
835                        *c1_idx,
836                        *fee,
837                        *tick_spacing,
838                        *hooks_idx,
839                        *zfo,
840                        *amount,
841                    )
842                    .expect("V4 swap compact args in range"),
843                ),
844                PlanStep::V4TakeDelta {
845                    currency_idx,
846                    recipient_idx,
847                    ..
848                } => out
849                    .extend_from_slice(&encoders::enc_v4_take_delta(*currency_idx, *recipient_idx)),
850                PlanStep::V4SettleAll => out.extend_from_slice(&encoders::enc_v4_settle_all()),
851                PlanStep::V4TakeCompact {
852                    currency_idx,
853                    recipient_idx,
854                    amount,
855                    ..
856                } => out.extend_from_slice(
857                    &encoders::enc_v4_take_compact(*currency_idx, *recipient_idx, *amount)
858                        .expect("V4 take compact amount in range"),
859                ),
860                PlanStep::V4SettleDelta { currency_idx, .. } => {
861                    out.extend_from_slice(&encoders::enc_v4_settle_delta(*currency_idx));
862                }
863                PlanStep::V4Sync { currency_idx, .. } => {
864                    out.extend_from_slice(&encoders::enc_v4_sync(*currency_idx));
865                }
866                PlanStep::V4Settle { .. } => {
867                    out.extend_from_slice(&encoders::enc_v4_settle());
868                }
869                PlanStep::WethWithdraw { amount, .. } => {
870                    out.extend_from_slice(&encoders::enc_weth_withdraw(U256::from(*amount)));
871                }
872                PlanStep::WethDeposit { amount, .. } => {
873                    out.extend_from_slice(&encoders::enc_weth_deposit(U256::from(*amount)));
874                }
875                PlanStep::V4Batch { entries, open_weth } => {
876                    let batch: Vec<encoders::V4BatchEntry> = entries
877                        .iter()
878                        .map(|e| encoders::V4BatchEntry {
879                            c0_idx: e.c0_idx,
880                            c1_idx: e.c1_idx,
881                            fee: e.fee,
882                            tick_spacing: e.tick_spacing,
883                            hooks_idx: e.hooks_idx,
884                            zfo: e.zfo,
885                            amount_u96: e.amount,
886                        })
887                        .collect();
888                    let encoded = if *open_weth {
889                        encoders::enc_v4_batch_open_weth(&batch)
890                    } else {
891                        encoders::enc_v4_batch(&batch)
892                    }
893                    .expect("V4 batch <= 8 entries + uint96 amounts");
894                    out.extend_from_slice(&encoded);
895                }
896                PlanStep::V4Mint {
897                    currency_idx,
898                    recipient_idx,
899                    amount,
900                    ..
901                } => out.extend_from_slice(
902                    &encoders::enc_v4_mint_compact(*currency_idx, *recipient_idx, *amount)
903                        .expect("V4 mint compact uint96 amount in range"),
904                ),
905            }
906        }
907    }
908    let mut out = Vec::new();
909    walk(plan, at, &mut out);
910    out
911}
912#[cfg(test)]
913mod tests {
914    #![expect(clippy::unwrap_used)]
915
916    use super::*;
917    use alloy::primitives::{address, Address};
918
919    #[test]
920    fn terminal_v2_uses_swap_calc_never_exact_out() {
921        // The terminal-V2 rule is expressed by `emit_terminal_hop` choosing
922        // `enc_v2_swap_calc` (0x21) — assert the encoder selection is CALC.
923        let h = V2HopInfo {
924            pool_address: address!("00000000000000000000000000000000000000aa"),
925            token0_address: address!("0000000000000000000000000000000000000001"),
926            token1_address: address!("0000000000000000000000000000000000000002"),
927            fee: 30,
928            zfo: true,
929        };
930        let mut at = AddressTable::new();
931        let mut out = Vec::new();
932        let inputs = ComposerInputs {
933            executor_address: Address::ZERO,
934            pool_manager_address: Address::ZERO,
935            weth_address: Address::ZERO,
936            optimal_input: 1000,
937            hop_outputs: &[1000],
938            consumed_inputs: &[1000],
939            opts: crate::composers::EncodeOptions::default(),
940        };
941        emit_terminal_hop(&mut at, &HopInfo::V2(h), &inputs, 0, 0, &mut out).unwrap();
942        // 0x21 = V2_SWAP_CALC (never exact-out V2_SWAP_COMPACT 0x20).
943        assert_eq!(out[0], 0x21, "terminal V2 must encode as V2_SWAP_CALC");
944        let _ = U256::ZERO;
945    }
946}