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}