Skip to main content

degenbot_executor/
grammar_ledger.rs

1//! Executor grammar — full axis model + a **ledger-validator** that
2//! makes the two real bug classes unrepresentable (ADR-029 D1/D2, D5).
3//!
4//! Two halves, per ADR-029 D4 (hybrid):
5//! * **axis types** — [`Prot`], [`FundingSource`], [`ProfitCapture`],
6//!   [`Bribe`], [`ShapeClass`] — the user-visible + derived axes that key a
7//!   family (D1). Funding source is a **runtime, per-path** choice
8//!   (strategy/operator, economic knob); capture, bribe, ledger and hop
9//!   coupling are the open sets the derivation reasons over.
10//! * **ledger-validator** — a [`LedgerOp`] IR + a stateful walker
11//!   ([`LedgerValidator`]) that simulates credit/debit per [`Ledger`] and
12//!   rejects any stream that violates **credit-before-debit**. It encodes the
13//!   two invariants from DS4OQD:
14//!   - `D0` — a `V4_TAKE*`/`V4_MINT*` may not debit `PM[currency]` unless a
15//!     prior swap left `PM[currency] ≥ amount` (the `v2_v2_v4`/`v2_v4_v4`
16//!     bug);
17//!   - terminal-V2 — a `V2_SWAP_CALC` may not consume `H[pool,input]` unless
18//!     the pair was credited first (the `2PT5HH` / `path-182449` über-draw).
19
20use std::collections::HashMap;
21
22use alloy::primitives::Address;
23
24use crate::composers::NATIVE_CURRENCY_ADDRESS;
25use crate::encoders::SENTINEL_SELF;
26
27// ═══════════════════════════════════════════════════════════════════════
28// Axis types (ADR-029 D1)
29// ═══════════════════════════════════════════════════════════════════════
30
31/// A hop-protocol family member.
32#[derive(Clone, Copy, PartialEq, Eq, Debug)]
33pub enum Prot {
34    V2,
35    V3,
36    V4,
37}
38
39/// The declared origin of a command stream's **entry (seed)** capital
40/// (ADR-029 D1). **One per stream, chosen at runtime by the strategy/operator**
41/// an economic knob (self-fund = cheaper gas for small opportunities; flash
42/// = access to outside capital for large ones).
43#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
44pub enum FundingSource {
45    /// Executor holds the entry WETH and pre-funds the leading hop.
46    SelfFund,
47    /// The outermost pool's own swap-callback extends the entry credit, repaid
48    /// by the path itself (in-path flash source).
49    #[default]
50    InPathFlash,
51    /// PoolManager delta accounting carries the entry credit (no-prefund V4).
52    PmLedger,
53    /// An external lender flash (Aave-shape; modeled, executable only after
54    /// the external-ledger work — VIXQYH stubs it).
55    ExternalLender,
56    /// Burn a held ERC-6909 claim to fund settlement.
57    Erc6909BurnToSettle,
58}
59
60/// The declared destination of the stream's **terminal profit** (the excess
61/// over the entry capital). **One per stream.** Modeled values are declared
62/// even where the current executor cannot yet express them.
63#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
64pub enum ProfitCapture {
65    /// Executor holds the terminal asset in its own balance.
66    #[default]
67    Custody,
68    /// Sent to `OWNER_ADDR`.
69    Owner,
70    /// Held as native ETH.
71    Native,
72    /// Minted as an ERC-6909 claim (needs `check_mode=2`).
73    Erc6909,
74    /// (Balancer) captured into the external Vault ledger — modeled, not yet
75    /// executable by the current executor.
76    BalancerVault,
77    /// follow-up (767TN5): the rare 'send accumulated profit to
78    /// another address' case. Defeats the profit assert (the sweep sends the
79    /// balance away, so combined_after < combined_before is expected). Routes
80    /// to the contract's `check_mode=3` (SWEEP) — the ONLY way to defeat the
81    /// U3WVLL assert. The recipient is an address-table entry the operator
82    /// populates (`SET_ADDRESS`) and passes as `bribe_recipient_idx` with
83    /// `bribe_bips=10000` for a full sweep.
84    SweepToAddress,
85}
86
87/// Whether and how the stream pays a **builder bribe** (ADR-029 Q3 — a **live**
88/// axis distinct from profit capture). `None` = no bribe (the default today).
89#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
90pub enum Bribe {
91    #[default]
92    None,
93    /// `bips` of profit (1–10000) to `recipient_idx` (0 = `block.coinbase`).
94    Some { bips: u16, recipient_idx: u8 },
95}
96
97/// A family's shape: hop-protocol sequence + the three output-bearing axes.
98#[derive(Clone, Debug)]
99pub struct ShapeClass {
100    /// Ordered hop protocols.
101    pub protocols: Vec<Prot>,
102    /// Which seed capital supplies the stream (runtime, per-path).
103    pub funding: FundingSource,
104    /// Where the terminal profit goes.
105    pub capture: ProfitCapture,
106    /// Whether/how a builder bribe is paid.
107    pub bribe: Bribe,
108}
109
110/// The stream-varying D1 output axes (candidate 4, `3BTR22`) — the axes a
111/// family's builder may **branch on in the produced STREAM**, as opposed to
112/// the runtime `check_mode`/`pack_config` config seam.
113#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
114pub enum Axis {
115    /// `FundingSource` (InPathFlash vs SelfFund) branches the stream.
116    Funding,
117    /// `ProfitCapture` (incl. the `erc6909_profit` legacy alias) branches the
118    /// stream (V4 `MINT`/`WETH_WITHDRAW` terminal capture).
119    Capture,
120    /// `Bribe` branches the stream. Never — bribes ride `pack_config`.
121    Bribe,
122}
123
124/// A family's declared set of stream-varying axes — which of
125/// `{funding, capture, bribe}` its builder actually branches on in the
126/// produced bytes (declared on the family→producer dispatch row, `3BTR22`),
127/// so a caller reads a family's honored axes off the declaration instead of
128/// reverse-engineering the builder body. **Declaration, not behavior:** a
129/// family whose row leaves an axis off derives it implicitly (InPathFlash
130/// funding) or reaches it only via the on-chain `check_mode` config, never a
131/// stream byte.
132#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
133pub struct AxisSupport {
134    /// The funding axis branches this family's stream.
135    pub funding: bool,
136    /// The capture axis branches this family's stream (V4 terminal capture).
137    pub capture: bool,
138    /// The bribe axis branches this family's stream (always false today).
139    pub bribe: bool,
140}
141
142impl AxisSupport {
143    /// No output axis varies the stream — the family derives them all.
144    #[must_use]
145    pub const fn none() -> Self {
146        Self {
147            funding: false,
148            capture: false,
149            bribe: false,
150        }
151    }
152
153    /// Only `funding` (the SelfFund/InPathFlash branch) varies the stream.
154    #[must_use]
155    pub const fn funding() -> Self {
156        Self {
157            funding: true,
158            capture: false,
159            bribe: false,
160        }
161    }
162
163    /// Only `capture` (V4 terminal capture) varies the stream.
164    #[must_use]
165    pub const fn capture() -> Self {
166        Self {
167            funding: false,
168            capture: true,
169            bribe: false,
170        }
171    }
172
173    /// Whether a specific axis varies the stream.
174    #[must_use]
175    pub const fn is_honored(self, axis: Axis) -> bool {
176        match axis {
177            Axis::Funding => self.funding,
178            Axis::Capture => self.capture,
179            Axis::Bribe => self.bribe,
180        }
181    }
182
183    /// The axes that vary the stream, most-significant-axis last.
184    #[must_use]
185    pub fn honored_axes(self) -> Vec<Axis> {
186        let mut axes = Vec::new();
187        if self.funding {
188            axes.push(Axis::Funding);
189        }
190        if self.capture {
191            axes.push(Axis::Capture);
192        }
193        if self.bribe {
194            axes.push(Axis::Bribe);
195        }
196        axes
197    }
198}
199
200// ═══════════════════════════════════════════════════════════════════════
201// Ledgers (ADR-029 D2 — an open set, never a closed enum)
202// ═══════════════════════════════════════════════════════════════════════
203
204/// An accounting location a command reads/writes. The five current instances
205/// plus the open extension point for external Vault/lender ledgers (a config
206/// change, not a new grammar shape).
207#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
208pub enum Ledger {
209    /// Executor's ERC-20 balance of `token` (incl. WETH).
210    Erc20(Address),
211    /// Executor's native ETH balance.
212    Native,
213    /// PoolManager delta for `currency` (positive = PM owes executor).
214    Pm(Address),
215    /// Executor's ERC-6909 held claim for `currency`.
216    Erc6909(Address),
217    /// V2 pair-handoff: tokens deposited into `pool` but not yet in reserves.
218    PairHandoff(Address),
219    /// (Extension) an external Balancer-shaped Vault / Aave-shaped lender.
220    External(&'static str),
221}
222
223// ═══════════════════════════════════════════════════════════════════════
224// LedgerOp IR + validator
225// ═══════════════════════════════════════════════════════════════════════
226
227/// A single command expressed as a ledger operation — the IR the validator
228/// reasons over, decoupled from byte layout (so it validates the *decisions*,
229/// not a specific encoding; the wire form is an encoder concern, ADR-029 D5).
230#[derive(Clone, Copy, PartialEq, Eq, Debug)]
231pub enum LedgerOp {
232    /// A `V4_SWAP_*`: creates `PM[out]` credit AND `PM[in]` debt (both legs
233    /// modeled so the net-zero-at-unlock-close invariant is checkable). The
234    /// concrete output currency is what the downstream take/mint consumes.
235    V4Swap {
236        in_currency: Address,
237        in_amount: u128,
238        out_currency: Address,
239        out_amount: u128,
240    },
241    /// `V4_TAKE(cur→rcp, amount)` — debits `PM[cur]` credit (D0). When
242    /// `repays_flash` is `Some(pool)`, the take is a flash repayment: it
243    /// saturating-repays `flash_debt[cur]` by `amount` (no executor Erc20
244    /// debit — the take draws from the PM, not the executor balance). The
245    /// `V4TakeCompact(→ V3 pool)` repayment case (e.g. the `v4_v4_v3` tail).
246    Take {
247        currency: Address,
248        amount: u128,
249        repays_flash: Option<Address>,
250    },
251    /// `V4_MINT(cur, amount)` — converts `PM[cur]` credit to `F[cur]` (D0).
252    Mint { currency: Address, amount: u128 },
253    /// `V4_SETTLE(cur, amount)` — the executor pays `amount` of `cur` into the
254    /// PM, cancelling debt: `PM[cur] += amount` (nets a negative delta toward
255    /// zero). The net-zero-at-unlock-close invariant is the V4 master rule
256    /// (checked at `V4UnlockEnd`, below).
257    V4Settle { currency: Address, amount: u128 },
258    /// `V4_SETTLE_DELTA(cur)` — auto-settle one currency: nets `PM[cur]` to 0
259    /// (if negative, executor pays; if positive, take to executor).
260    V4SettleDelta { currency: Address },
261    /// `V4_SETTLE_ALL` — auto-settle every touched PM currency to 0.
262    V4SettleAll,
263    /// `V4_BATCH_OPEN_WETH` (0x43) pairing arm — no PM effect (the per-entry
264    /// `V4Swap` ops already model the batch). The batch left the positive
265    /// WETH delta OPEN (no tail take), so the stream must convert it with a
266    /// `Mint { currency: weth }` before `V4UnlockEnd` — otherwise the PM's
267    /// `delta()` settles the leftover delta to the caller at callback end
268    /// (TGUZCT/SW42JA). Disarmed by a mint of the same currency.
269    OpenWethPairing { weth: Address },
270    /// `V4_TAKE_DELTA(cur→rcp)` — take the ENTIRE positive `PM[cur]` delta to
271    /// `rcp` (the profit capture). Debits whatever credit `PM[cur]` holds
272    /// (amount is runtime state — the current balance). Requires `PM[cur] > 0`
273    /// immediately before (the D0 credit-before-debit rule on the PM ledger).
274    /// When the recipient is a V2 pool (`seeds_pool`), the taken credit seeds
275    /// that pool's `H[pool]` (the 2PT5HH rule across a V4→V2 boundary — e.g.
276    /// the `v3_v4_v2` family).
277    V4TakeDelta {
278        currency: Address,
279        recipient_idx: u8,
280        seeds_pool: Option<Address>,
281    },
282    /// `V4_UNLOCK` callback end — the master V4 invariant: every touched
283    /// `PM[currency]` must net to zero by callback end. The validator
284    /// rejects if any PM delta is nonzero. Emitted by the Plan's `V4Unlock`
285    /// node after its inner Plan.
286    V4UnlockEnd,
287    /// Seed a V2 pair's excess (credit `H[pool]`) — a transfer/take *to the
288    /// pair* that a later `SwapCalc` consumes.
289    SeedPair { pool: Address, amount: u128 },
290    /// `V2_SWAP_CALC(pool)` — consumes `H[pool]` credit (terminal-V2 rule) AND
291    /// credits `out_currency` to the executor (the swap's computed output, the
292    /// profit / downstream repayment source). Option (B): swaps credit their
293    /// output so the executor ledger fully accounts (ADR-029 D5).
294    SwapCalc {
295        pool: Address,
296        amount_in: u128,
297        out_currency: Address,
298        out_amount: u128,
299        /// Where the swap's computed output goes (drives the downstream seed /
300        /// credit). [`SwapRecipient::Executor`] credits the executor (the 2-hop
301        /// terminal behavior, byte-identical); [`SwapRecipient::Pool`] seeds
302        /// that pool's `H[pool]` (a mid-chain calculator paying the next pool);
303        /// [`SwapRecipient::PoolManager`] pays into the PM (the following
304        /// `V4Settle`/net-zero accounts it) — no executor credit.
305        recipient: SwapRecipient,
306    },
307    // ── POC (6SRC23): V2/V3 flash-credit chain for `v2_v3` (ADR-029 D4/D5). ──
308    /// A `V2_SWAP_COMPACT` flash: the pool extends `out_currency` credit to the
309    /// executor (the swap output, before repayment), and incurs an `in_currency`
310    /// flash debt repayable within the callback. Extends the executor `Erc20`
311    /// ledger (the same credit-before-debit rule as `PM`, on a different ledger).
312    /// [`SwapRecipient`] routes where the output goes (see below).
313    V2Flash {
314        out_currency: Address,
315        out_amount: u128,
316        in_currency: Address,
317        in_amount: u128,
318        /// Where the flash's output goes: Executor (credit Erc20), Pool(p)
319        /// (seed p's handoff — a V3 flash feeding the terminal V2),
320        /// PoolRepay(p) (repay p's flash debt — a V3→V3 repayment),
321        /// PoolManager (pay the PM — the following V4Settle accounts it).
322        recipient: SwapRecipient,
323    },
324    /// A `V3_SWAP_COMPACT` flash: same shape as [`V2Flash`] — the V3 pool credits
325    /// `out_currency` and is owed `in_currency` within the callback.
326    V3Flash {
327        out_currency: Address,
328        out_amount: u128,
329        in_currency: Address,
330        in_amount: u128,
331        recipient: SwapRecipient,
332    },
333    /// An `ERC20_TRANSFER(cur→rcp, amount)` debiting the executor's `Erc20[cur]`
334    /// balance. When `repays_flash` is `Some(pool)`, the transfer is a flash
335    /// repayment: it debits `min(amount, flash_debt[cur])` (saturating against
336    /// the owed debt) so the auto-pay-at-callback-end case (empty-callback V2/V3
337    /// flash) zeroes the debt without over-debiting. Requires the executor held
338    /// credit ≥ the debited amount immediately before (credit-before-debit).
339    Erc20Transfer {
340        currency: Address,
341        amount: u128,
342        repays_flash: Option<Address>,
343    },
344    /// A self-fund seed — the executor **holds** `amount` of `currency` as entry
345    /// capital before the stream starts (ADR-029 FundingSource::SelfFund). Credits
346    /// `Erc20[currency]` (the same ledger flashes extend, just sourced from the
347    /// executor's own balance rather than a flash). Not a command; a stream
348    /// precondition modeled so the SelfFund families' repayments validate.
349    SelfFund { currency: Address, amount: u128 },
350    /// The executor-side credit half of a **cross-ledger move** — a V4
351    /// `V4_TAKE_COMPACT(cur→SELF)` physically transfers `cur` from the PM to the
352    /// executor's balance, so alongside the PM debit (`Take`) the executor's
353    /// `Erc20[cur]` is credited by `amount`. Without this, a downstream V2/V3
354    /// flash that consumes `cur` (e.g. the V3 auto-repay in `v4_v3`) would see
355    /// `Erc20[cur] == 0` and be rejected — the cross-ledger analogue of D0
356    /// (the boundary take must precede the outside-ledger consume).
357    Erc20Credit { currency: Address, amount: u128 },
358    /// The executor→PM native pay-in leg of a **native settle**.
359    /// On-chain, native flows to the PM as `msg.value` on the `V4_SETTLE`
360    /// call (no separate transfer instruction); this op models the executor's
361    /// native balance debit explicitly (the settle credits PM via
362    /// `V4SettleDelta`/`V4Settle`). Keeping the debit separate from the settle
363    /// credit means a missing settle half is caught by PM-net-zero rather than
364    /// silently absorbed — the gate's core value. Requires `Native ≥ amount`
365    /// immediately before (credit-before-debit; a `WethWithdraw` or native V4
366    /// take must have produced the native first).
367    NativeTransfer { amount: u128 },
368    /// The native credit half of a `V4TakeCompact(native→SELF)` — the native
369    /// output physically arrives at the executor's native balance (the mirror
370    /// of `Erc20Credit` for the native ledger). Pure credit; a later
371    /// `NativeTransfer` or `WethDeposit` consumes it.
372    NativeCredit { amount: u128 },
373    /// `WETH_WITHDRAW(amount)` — unwrap WETH to native: debits `Erc20[WETH]`
374    /// and credits `Native` (the source of the native that a `NativeTransfer`
375    /// then pays into the PM). Carries `weth` so the validator debits the right
376    /// Erc20 entry.
377    WethWithdraw { weth: Address, amount: u128 },
378    /// `WETH_DEPOSIT(amount)` — wrap native to WETH: debits `Native` and
379    /// credits `Erc20[WETH]` (the native came from a V4 `V4TakeCompact(native→
380    /// SELF)`).
381    WethDeposit { weth: Address, amount: u128 },
382    /// `EXTERNAL_FLASH` (VIXQYH stub) — a flash from an external-held ledger
383    /// (a Balancer-shaped Vault or an Aave-shaped lender). Extends the
384    /// executor's balance on that ledger + incurs flash debt, mirroring
385    /// `V2Flash`/`V3Flash` but on a pluggable [`BalanceLedger`] (the `ledger`
386    /// discriminant identifies WHICH external ledger, so the validator routes
387    /// the balance leg to the right impl). The additive-capability proof:
388    /// one new op variant + one new `BalanceLedger` impl = a new funding
389    /// source composed across the existing protocol shapes, NOT a new adapter
390    /// per (protocol × position × neighbor × funding × capture) cell.
391    ExternalFlash {
392        /// Which external ledger (`0` = Vault, `1` = lender; the validator
393        /// holds a `Vec<ExternalLedger>` it indexes into here).
394        ledger: u8,
395        /// The currency credited.
396        out_currency: Address,
397        /// The credited amount.
398        out_amount: u128,
399        /// The currency owed back (the flash debt).
400        in_currency: Address,
401        /// The owed amount (incl. the flash premium for a lender).
402        in_amount: u128,
403    },
404    /// `EXTERNAL_REPAY` (VIXQYH stub) — the repayment half of an
405    /// [`ExternalFlash`]: debits the external-ledger balance (checked — D0
406    /// credit-before-debit, the same invariant as `Erc20Transfer` but routed to
407    /// the external `BalanceLedger`) and zeroes the flash debt. Composes the
408    /// repayment pivot with the existing protocols without a per-family
409    /// adapter.
410    ExternalRepay {
411        /// Which external ledger (mirrors [`ExternalFlash::ledger`]).
412        ledger: u8,
413        /// The currency repaid.
414        currency: Address,
415        /// The amount repaid.
416        amount: u128,
417    },
418}
419
420/// Where a V2 `SWAP_CALC` / `SWAP_DIRECT` computed output goes.
421///
422/// [`SwapRecipient::Executor`] credits the executor (the 2-hop terminal behavior —
423/// byte-identical precedent). [`SwapRecipient::Pool`] seeds that pool's
424/// `H[pool]` (the mid-chain handoff — the donor's seed is consumed, the output
425/// seeds the next pool). [`SwapRecipient::PoolManager`] pays into the PM (the
426/// following `V4Settle` / net-zero accounts it) — no executor credit.
427#[derive(Clone, Copy, PartialEq, Eq, Debug)]
428pub enum SwapRecipient {
429    /// The swap's output credits the executor.
430    Executor,
431    /// The swap's output seeds this pool's pair-handoff (a V2 pre-fund).
432    Pool(Address),
433    /// The swap's output REPAYS this pool's flash debt (a V3 flash being
434    /// repaid — saturating `flash_debt[out_currency]`; no `SeedPair` credit).
435    /// Kept structurally distinct from [`SwapRecipient::Pool`] so a future
436    /// author can't seed when they meant to repay (or vice versa).
437    PoolRepay(Address),
438    /// The swap's output pays into the PoolManager (PM-pay-in leg).
439    PoolManager,
440}
441
442/// A `V4_SWAP` term op returned by the proto-trace builder — kept separate from
443/// [`LedgerOp`] so the validator can synthesize the credit relations without
444/// trusting a hand-written credit ledger.
445#[derive(Clone, Copy, PartialEq, Eq, Debug)]
446pub enum LedgerEffect {
447    /// Create `PM[cur]` credit of `amount`.
448    PmCredit(Address, u128),
449    /// Create `H[pool]` credit of `amount` (pair seeded).
450    PairCredit(Address, u128),
451}
452
453impl LedgerOp {
454    /// A single processable op. The validator special-cases term helpers
455    /// ([`LedgerEffect`]) directly; this is the effect of non-term ops.
456    ///
457    /// `V2Flash`/`V3Flash`/`Erc20Transfer` are handled directly in [`LedgerValidator::push`]` ([`LedgerEffect`] covers only the PM/pair ledgers).
458    fn effect(&self) -> Option<LedgerEffect> {
459        match *self {
460            LedgerOp::SeedPair { pool, amount } => Some(LedgerEffect::PairCredit(pool, amount)),
461            LedgerOp::V4Swap { .. }
462            | LedgerOp::Take { .. }
463            | LedgerOp::Mint { .. }
464            | LedgerOp::V4Settle { .. }
465            | LedgerOp::V4SettleDelta { .. }
466            | LedgerOp::V4SettleAll
467            | LedgerOp::V4TakeDelta { .. }
468            | LedgerOp::V4UnlockEnd
469            | LedgerOp::SwapCalc { .. }
470            | LedgerOp::V2Flash { .. }
471            | LedgerOp::V3Flash { .. }
472            | LedgerOp::Erc20Transfer { .. }
473            | LedgerOp::SelfFund { .. }
474            | LedgerOp::Erc20Credit { .. }
475            | LedgerOp::NativeTransfer { .. }
476            | LedgerOp::NativeCredit { .. }
477            | LedgerOp::WethWithdraw { .. }
478            | LedgerOp::WethDeposit { .. }
479            | LedgerOp::ExternalFlash { .. }
480            | LedgerOp::ExternalRepay { .. }
481            | LedgerOp::OpenWethPairing { .. } => None,
482        }
483    }
484}
485
486/// Rejects a command stream if it violates **credit-before-debit** within any
487/// ledger (the DS4OQD invariants: D0 take/mint-before-credit; terminal-V2
488/// über-draw). Fail-fast on the first violation.
489///
490/// `take`/`mint` require `PM[currency] ≥ amount` **immediately before**; a
491/// `SwapCalc` requires the pair to have been seeded (`H[pool] ≥ 0`) first.
492///
493/// POC (`6SRC23`): the executor's own `Erc20[currency]` balance is the same
494/// credit-before-debit ledger for V2/V3 flash swaps — a flash repayment
495/// (`Erc20Transfer`) is only legal after a prior flash extended the credit.
496/// Flash debts must be fully repaid by `finish()` (the V2/V3 analogue of the
497/// V4 "every delta nets to zero by callback end" invariant).
498#[derive(Debug, Default)]
499pub struct LedgerValidator {
500    /// `PM[currency]` balance (positive = PM owes executor) — behind the
501    /// [`PmLedger`] newtype (ADR-029 D2 open-set: a `dyn BalanceLedger`).
502    pm: PmLedger,
503    /// `H[pool]` — seeded-but-unswapped pair excess per pool.
504    pair: HashMap<Address, u128>,
505    /// `Erc20[currency]` — the executor's own balance per currency (extended
506    /// by flash swaps, consumed by `Erc20Transfer`) — behind the
507    /// [`Erc20Ledger`] newtype.
508    erc20: Erc20Ledger,
509    /// External held-balance ledgers (VIXQYH stub — a Balancer Vault and/or
510    /// an Aave lender). Indexed by `LedgerOp::ExternalFlash::ledger`. Empty by
511    /// default; populated via [`Self::with_external_ledgers`]. The additive
512    /// proof: the D0 gate enforces on these uniformly via the `BalanceLedger`
513    /// trait, no new grammar shape.
514    externals: Vec<ExternalLedger>,
515    /// `Native` — the executor's native ETH balance. Extended by V4 native
516    /// takes (`V4TakeCompact(native→SELF)`) and `WethWithdraw`; consumed by
517    /// `NativeTransfer` (the pay-into-PM leg of a native settle) and
518    /// `WethDeposit` (wrap). Same credit-before-debit rule.
519    native: i128,
520    /// Outstanding flash debt per currency (owed by the executor, awaiting
521    /// repayment within a callback). Checked zero at `finish()`.
522    flash_debt: HashMap<Address, u128>,
523    /// Armed open-weth (0x43) batch pairing (TGUZCT/SW42JA): `Some(weth)`
524    /// after an `OpenWethPairing` op until a `Mint { currency: weth }`
525    /// disarms it; `V4UnlockEnd` rejects while set.
526    open_weth_pairing: Option<Address>,
527}
528
529/// Why a stream was rejected — the invariant that fired and the offending op.
530#[derive(Clone, Copy, PartialEq, Eq, Debug)]
531pub enum ValidationError {
532    /// A `take`/`mint` fired before the PoolManager held credit (the `D0`
533    /// bug class).
534    TakeBeforeCredit {
535        currency: Address,
536        wanted: u128,
537        have: i128,
538    },
539    /// A `V2_SWAP_CALC` fired before the pair was seeded (the terminal-V2 /
540    /// `2PT5HH` über-draw class).
541    SwapCalcBeforeCredit { pool: Address },
542    /// An `ERC20_TRANSFER` debiting the executor fired before the executor held
543    /// `currency` credit (the V2/V3 flash-repay-before-credit class; surfaced
544    /// by the `6SRC23` POC — byte-parity cannot see this ordering defect).
545    Erc20TransferBeforeCredit {
546        currency: Address,
547        wanted: u128,
548        have: i128,
549    },
550    /// A flash debt was left unpaid at `finish()` — the V2/V3 analogue of the
551    /// V4 "every delta nets to zero by callback end" invariant.
552    FlashDebtUnpaid { currency: Address, amount: u128 },
553    /// A `V4_UNLOCK` closed with a nonzero `PM[currency]` delta — the V4 master
554    /// invariant violation (a touched currency was not settled to zero by
555    /// callback end).
556    PmDeltaNonzero { currency: Address, delta: i128 },
557    /// A `NativeTransfer` (the executor→PM native pay-in leg of a native
558    /// settle) debited the executor's native balance before it held credit (the
559    /// native analogue of `Erc20TransferBeforeCredit`). Surfaced by the
560    /// a `WethWithdraw` (or native V4 take) must
561    /// precede the native pay-in.
562    NativeTransferBeforeCredit { wanted: u128, have: i128 },
563    /// An `ExternalFlash`/`ExternalRepay` referenced an external-ledger index
564    /// not registered on the validator (VIXQYH stub). The validator must be
565    /// constructed with `with_external_ledgers` covering the index the stream
566    /// uses.
567    UnknownExternalLedger { ledger: u8 },
568    /// A `V4_BATCH_OPEN_WETH` (0x43) left the WETH delta OPEN and the unlock
569    /// closed before a WETH `V4_MINT_COMPACT` consumed it — the PM's
570    /// `delta()` would settle the leftover delta to the caller at callback
571    /// end (TGUZCT: with the open-weth batch, a batch without its mint is
572    /// unrepresentable).
573    OpenWethBatchNotFollowedByMint { weth: Address },
574}
575
576// ═══════════════════════════════════════════════════════════════════════
577// `BalanceLedger` trait + concrete ledgers (ADR-029 D2 — the open-set
578// abstraction)
579// ═══════════════════════════════════════════════════════════════════════
580// The Address-keyed signed-balance ledgers (PM delta + executor ERC-20)
581// share `credit` / `debit` (the credit-before-debit D0 check) / `balance`.
582// That shared interface is the open-set seam: an external Vault/lender's
583// held-balance or delta ledger (VIXQYH) plugs in as one more `BalanceLedger`
584// impl, and the validator's D0 enforcement applies uniformly. PM-specific ops
585// (debt-creation, settle, take-delta, check-all-zero) stay inherent — they
586// don't generalize across ledgers. `native` (scalar), `pair` (unsigned
587// consume), `flash_debt` (tracking) are structurally different and stay
588// specialized until a concrete external-ledger use case demands the trait.
589// (Named `BalanceLedger` to avoid clashing with the [`Ledger`] location-identifier
590// enum above.)
591
592/// A signed-balance ledger keyed by address (ADR-029 D2). The credit-before-
593/// debit invariant: [`Self::debit`] checks the held balance is sufficient
594/// before withdrawing, failing with the impl's typed error otherwise.
595pub trait BalanceLedger {
596    /// Credit `amount` to `key` (may go negative for debt ledgers like PM).
597    fn credit(&mut self, key: Address, amount: u128);
598    /// Debit `amount` from `key` — the D0 credit-before-debit check. Returns
599    /// `Err` if the balance is insufficient; the error variant is per-impl
600    /// (`TakeBeforeCredit` for PM, `Erc20TransferBeforeCredit` for Erc20).
601    ///
602    /// # Errors
603    ///
604    /// Returns [`ValidationError::TakeBeforeCredit`] (PM) or
605    /// [`ValidationError::Erc20TransferBeforeCredit`] (Erc20) when the held
606    /// balance is below `amount`.
607    fn debit(&mut self, key: Address, amount: u128) -> Result<(), ValidationError>;
608    /// The current signed balance at `key` (positive = credit, negative = debt).
609    fn balance(&self, key: Address) -> i128;
610}
611
612/// The PoolManager delta ledger (`PM[token]`): positive = PM owes executor
613/// (credit), negative = executor owes PM (debt). Implements [`Ledger`] (the
614/// `Take`/`Mint` ops → `debit`); carries inherent ops for V4-specific moves:
615/// `debit_debt` (unchecked — `V4Swap` creates debt without a D0 check),
616/// `take_delta` (zero the whole positive delta), and the settle family.
617#[derive(Debug, Default)]
618pub struct PmLedger {
619    deltas: HashMap<Address, i128>,
620}
621
622impl PmLedger {
623    /// Debit `amount` from `key` WITHOUT a credit check (creates PM debt).
624    /// `V4Swap`'s input leg — debt is the point (settled later).
625    fn debit_debt(&mut self, key: Address, amount: u128) {
626        *self.deltas.entry(key).or_default() -= amount.cast_signed();
627    }
628
629    /// `V4_TAKE_DELTA` — take the ENTIRE positive `PM[key]` delta to the
630    /// recipient. Requires `PM[key] > 0` immediately before (D0). Zeros it.
631    fn take_delta(&mut self, key: Address) -> Result<u128, ValidationError> {
632        let have = *self.deltas.get(&key).unwrap_or(&0);
633        if have <= 0 {
634            return Err(ValidationError::TakeBeforeCredit {
635                currency: key,
636                wanted: 1,
637                have,
638            });
639        }
640        self.deltas.insert(key, 0);
641        Ok(have.cast_unsigned())
642    }
643
644    /// `V4_SETTLE_DELTA(cur)` — zero one currency's PM delta.
645    fn settle_key(&mut self, key: Address) {
646        self.deltas.insert(key, 0);
647    }
648
649    /// `V4_SETTLE_ALL` — zero every touched PM currency.
650    fn settle_all(&mut self) {
651        for v in self.deltas.values_mut() {
652            *v = 0;
653        }
654    }
655
656    /// `V4_UNLOCK` callback end — the master invariant: every touched
657    /// `PM[currency]` must net to zero. Returns the first nonzero delta if any.
658    #[cfg(test)]
659    fn first_nonzero(&self) -> Option<(Address, i128)> {
660        self.deltas
661            .iter()
662            .find(|(_, delta)| **delta != 0)
663            .map(|(cur, delta)| (*cur, *delta))
664    }
665}
666
667impl BalanceLedger for PmLedger {
668    fn credit(&mut self, key: Address, amount: u128) {
669        *self.deltas.entry(key).or_default() += amount.cast_signed();
670    }
671    fn debit(&mut self, key: Address, amount: u128) -> Result<(), ValidationError> {
672        let have = *self.deltas.get(&key).unwrap_or(&0);
673        if have < amount.cast_signed() {
674            return Err(ValidationError::TakeBeforeCredit {
675                currency: key,
676                wanted: amount,
677                have,
678            });
679        }
680        self.deltas.insert(key, have - amount.cast_signed());
681        Ok(())
682    }
683    fn balance(&self, key: Address) -> i128 {
684        *self.deltas.get(&key).unwrap_or(&0)
685    }
686}
687
688/// The executor's ERC-20 balance ledger (`E[token]`, incl. WETH). Implements
689/// [`Ledger`]; the D0 check (`debit`) emits `Erc20TransferBeforeCredit`.
690#[derive(Debug, Default)]
691pub struct Erc20Ledger {
692    balances: HashMap<Address, i128>,
693}
694
695impl BalanceLedger for Erc20Ledger {
696    fn credit(&mut self, key: Address, amount: u128) {
697        *self.balances.entry(key).or_default() += amount.cast_signed();
698    }
699    fn debit(&mut self, key: Address, amount: u128) -> Result<(), ValidationError> {
700        let have = *self.balances.get(&key).unwrap_or(&0);
701        if have < amount.cast_signed() {
702            return Err(ValidationError::Erc20TransferBeforeCredit {
703                currency: key,
704                wanted: amount,
705                have,
706            });
707        }
708        self.balances.insert(key, have - amount.cast_signed());
709        Ok(())
710    }
711    fn balance(&self, key: Address) -> i128 {
712        *self.balances.get(&key).unwrap_or(&0)
713    }
714}
715
716/// A stub external held-balance ledger (VIXQYH — ADR-029 D6 additive proof):
717/// the shape a Balancer Vault's per-token balance or an Aave lender's
718/// supplied-liquidity balance takes from the validator's perspective. Same
719/// Address-keyed signed-balance + D0 credit-before-debit semantics as
720/// [`Erc20Ledger`] — that's the point: it composes as one more `BalanceLedger`
721/// impl, NOT a new grammar shape. The validator enforces D0 on it uniformly.
722/// Real Vault/lender mechanics (callback wiring, premium math) live behind a
723/// per-protocol interface in a separate epic; this stub proves the ordering
724/// gate already accommodates the new ledger.
725#[derive(Debug, Default)]
726pub struct ExternalLedger {
727    balances: HashMap<Address, i128>,
728}
729
730impl BalanceLedger for ExternalLedger {
731    fn credit(&mut self, key: Address, amount: u128) {
732        *self.balances.entry(key).or_default() += amount.cast_signed();
733    }
734    fn debit(&mut self, key: Address, amount: u128) -> Result<(), ValidationError> {
735        // The D0 check maps to the Erc20TransferBeforeCredit shape — the
736        // external-ledger debit is a "repay/transfer out of a held balance"
737        // and fails the same way when insufficient.
738        let have = *self.balances.get(&key).unwrap_or(&0);
739        if have < amount.cast_signed() {
740            return Err(ValidationError::Erc20TransferBeforeCredit {
741                currency: key,
742                wanted: amount,
743                have,
744            });
745        }
746        self.balances.insert(key, have - amount.cast_signed());
747        Ok(())
748    }
749    fn balance(&self, key: Address) -> i128 {
750        *self.balances.get(&key).unwrap_or(&0)
751    }
752}
753
754impl LedgerValidator {
755    /// Configure the external held-balance ledgers (VIXQYH stub). The
756    /// validator routes `LedgerOp::ExternalFlash`/`ExternalRepay` to the
757    /// `ExternalLedger` at the index the op carries, enforcing D0 on it via
758    /// the `BalanceLedger` trait. Returns `self` for chaining.
759    #[must_use]
760    pub fn with_external_ledgers(mut self, ledgers: Vec<ExternalLedger>) -> Self {
761        self.externals = ledgers;
762        self
763    }
764    /// The ledger balance effect of one op; non-term ops are no-ops here and
765    /// are checked (enforced) in [`Self::push`] instead.
766    fn apply(&mut self, e: LedgerEffect) {
767        match e {
768            LedgerEffect::PmCredit(cur, amt) => {
769                self.pm.credit(cur, amt);
770            }
771            LedgerEffect::PairCredit(pool, amt) => {
772                *self.pair.entry(pool).or_default() += amt;
773            }
774        }
775    }
776
777    /// Push one ledger op, enforcing credit-before-debit. Returns `Err` on the
778    /// first violation (the op is **not** applied to the state on error).
779    ///
780    /// # Errors
781    ///
782    /// Returns [`ValidationError`] on the first invariant violation: a
783    /// `Take`/`Mint`/`Erc20Transfer`/`Weth*` debit before credit
784    /// (`TakeBeforeCredit`/`Erc20TransferBeforeCredit`/`NativeTransferBeforeCredit`),
785    /// an unknown external-ledger index (`UnknownExternalLedger`), a nonzero
786    /// PM delta at `V4UnlockEnd` (`PmDeltaNonzero`), or a `SwapCalc` on an
787    /// unseeded pair (`SwapCalcBeforeCredit`).
788    #[expect(clippy::too_many_lines)]
789    pub fn push(&mut self, op: LedgerOp) -> Result<(), ValidationError> {
790        // Term ops create credit; apply them first so later debits see it.
791        if let Some(e) = op.effect() {
792            self.apply(e);
793            return Ok(());
794        }
795        match op {
796            LedgerOp::SeedPair { .. } => Ok(()), // handled above
797            // V4 swap: PM[in] −= in_amount (debt), PM[out] += out_amount
798            // (credit). Both legs so the net-zero-at-unlock-close invariant is
799            // checkable (option B full modeling on the PM ledger).
800            LedgerOp::V4Swap {
801                in_currency,
802                in_amount,
803                out_currency,
804                out_amount,
805            } => {
806                // PM[in] debt (unchecked — debt is the point); PM[out] credit.
807                self.pm.debit_debt(in_currency, in_amount);
808                self.pm.credit(out_currency, out_amount);
809                Ok(())
810            }
811            // V4 settle: executor pays `amount` of `currency` into the PM,
812            // cancelling debt (PM[currency] += amount).
813            LedgerOp::V4Settle { currency, amount } => {
814                self.pm.credit(currency, amount);
815                Ok(())
816            }
817            // V4_SETTLE_DELTA: auto-net one currency's PM delta to 0.
818            LedgerOp::V4SettleDelta { currency } => {
819                self.pm.settle_key(currency);
820                Ok(())
821            }
822            // V4_SETTLE_ALL: auto-net every touched PM currency to 0.
823            LedgerOp::V4SettleAll => {
824                self.pm.settle_all();
825                Ok(())
826            }
827            // V4_TAKE_DELTA: take the entire positive PM[currency] delta to rcp.
828            // Requires PM[currency] > 0 immediately before (credit-before-debit).
829            // WE45KC inc.2: when the recipient is SELF, the take physically delivers
830            // the asset to executor custody — model the receipt so a downstream
831            // `WethWithdraw` (ProfitCapture::Native) can debit it. (Native currency
832            // credits the Native ledger; ERC-20/WETH credits Erc20.)
833            LedgerOp::V4TakeDelta {
834                currency,
835                recipient_idx,
836                seeds_pool,
837            } => {
838                let amount = self.pm.take_delta(currency)?;
839                if recipient_idx == SENTINEL_SELF {
840                    if currency == NATIVE_CURRENCY_ADDRESS {
841                        self.native += amount.cast_signed();
842                    } else {
843                        self.erc20.credit(currency, amount);
844                    }
845                } else if let Some(pool) = seeds_pool {
846                    // The take hands the credit directly to a V2 pool (PM→pool
847                    // the 2PT5HH terminal-V2 rule across the V4 boundary):
848                    // seed its pair-handoff so a following `V2SwapCalc` sees it.
849                    let h = *self.pair.get(&pool).unwrap_or(&0);
850                    self.pair.insert(pool, h + amount);
851                }
852                Ok(())
853            }
854            // TGUZCT/SW42JA: the 0x43 open-weth batch arms the pairing — a
855            // WETH `Mint` before `V4UnlockEnd` must consume the open delta.
856            // Re-arming overwrites (the encoder emits at most one per unlock).
857            LedgerOp::OpenWethPairing { weth } => {
858                self.open_weth_pairing = Some(weth);
859                Ok(())
860            }
861            // V4_UNLOCK callback end: the master invariant — every touched
862            // PM currency must net to zero by callback end. (A prior
863            // `V4SettleAll` would have zeroed them; this catches any stream
864            // that forgot to settle.)
865            LedgerOp::V4UnlockEnd => {
866                if let Some(weth) = self.open_weth_pairing {
867                    return Err(ValidationError::OpenWethBatchNotFollowedByMint { weth });
868                }
869                // The unlock-close auto-settles POSITIVE PM deltas to the
870                // executor (the master-rule "net zero" credits the bot — a
871                // stream may validly leave surplus profit). A NEGATIVE delta is
872                // an unpaid executor input debt (the stream forgot to settle a
873                // swap input) and is rejected — the `V4SettleAll`/`V4SettleDelta`
874                // discipline the 3-hop builders follow.
875                for (currency, delta) in &self.pm.deltas {
876                    if *delta < 0 {
877                        return Err(ValidationError::PmDeltaNonzero {
878                            currency: *currency,
879                            delta: *delta,
880                        });
881                    }
882                }
883                for v in self.pm.deltas.values_mut() {
884                    *v = 0;
885                }
886                Ok(())
887            }
888            // V4_TAKE / V4_MINT: debits the PM credit (D0). A `Take` that is a
889            // flash repayment additionally saturating-repays `flash_debt[cur]`
890            // (the V4TakeCompact→V3-pool repayment — no executor Erc20 debit;
891            // the take draws from the PM).
892            LedgerOp::Take {
893                currency,
894                amount,
895                repays_flash: Some(_),
896            } => {
897                self.pm.debit(currency, amount)?;
898                let owed = self.flash_debt.entry(currency).or_default();
899                *owed = owed.saturating_sub(amount);
900                Ok(())
901            }
902            LedgerOp::Take {
903                currency, amount, ..
904            } => self.pm.debit(currency, amount),
905            LedgerOp::Mint { currency, amount } => {
906                self.pm.debit(currency, amount)?;
907                // TGUZCT/SW42JA: a mint of the paired currency consumes the
908                // open batch's WETH delta — disarms the pairing gate.
909                if Some(currency) == self.open_weth_pairing {
910                    self.open_weth_pairing = None;
911                }
912                Ok(())
913            }
914            LedgerOp::SwapCalc {
915                pool,
916                out_currency,
917                out_amount,
918                recipient,
919                ..
920            } => {
921                let have = *self.pair.get(&pool).unwrap_or(&0);
922                if have == 0 {
923                    return Err(ValidationError::SwapCalcBeforeCredit { pool });
924                }
925                // The pool's seeded excess is consumed by the swap. Where the
926                // output goes is recipient-driven: SELF credits the executor
927                // (option B: swaps credit their output so the executor ledger
928                // fully accounts); a Pool seeds the recipient's H[pool] (the
929                // mid-chain handoff); the PM pays into the PM (the following
930                // V4Settle/net-zero accounts it, no executor credit).
931                self.pair.insert(pool, have - 1);
932                match recipient {
933                    SwapRecipient::Executor => {
934                        self.erc20.credit(out_currency, out_amount);
935                    }
936                    SwapRecipient::Pool(p) => {
937                        let h = *self.pair.get(&p).unwrap_or(&0);
938                        self.pair.insert(p, h + out_amount);
939                    }
940                    SwapRecipient::PoolRepay(_) => {
941                        // The output repays a V3 flash pool's debt: saturating
942                        // reduction on `flash_debt[out_currency]` (the pool
943                        // param is documentary — the debt ledger is currency-,
944                        // not pool-keyed). No SeedPair, no executor credit.
945                        let owed = self.flash_debt.entry(out_currency).or_default();
946                        *owed = owed.saturating_sub(out_amount);
947                    }
948                    SwapRecipient::PoolManager => {}
949                }
950                Ok(())
951            }
952            // POC (6SRC23): V2/V3 flash swaps — term ops extending executor
953            // `Erc20` credit and incurring flash debt (repayable within the
954            // callback). Same credit-before-debit rule as `V4Swap`→`PM`, on the
955            // executor-ledger axis.
956            LedgerOp::V2Flash {
957                out_currency,
958                out_amount,
959                in_currency,
960                in_amount,
961                recipient,
962            }
963            | LedgerOp::V3Flash {
964                out_currency,
965                out_amount,
966                in_currency,
967                in_amount,
968                recipient,
969            } => {
970                // The flash extends the executor credit OR routes its output to
971                // a recipient: Executor → the executor's Erc20; Pool(p) → seed
972                // p's handoff (a V3 flash feeding the terminal V2);
973                // PoolRepay(p) → saturating-repay p's flash debt (a V3→V3
974                // repayment); PoolManager → pays the PM (the following
975                // V4Settle/net-zero accounts it, no executor credit).
976                match recipient {
977                    SwapRecipient::Executor => {
978                        self.erc20.credit(out_currency, out_amount);
979                    }
980                    SwapRecipient::Pool(pool) => {
981                        let have = *self.pair.get(&pool).unwrap_or(&0);
982                        self.pair.insert(pool, have + out_amount);
983                    }
984                    SwapRecipient::PoolRepay(_) => {
985                        let owed = self.flash_debt.entry(out_currency).or_default();
986                        *owed = owed.saturating_sub(out_amount);
987                    }
988                    SwapRecipient::PoolManager => {}
989                }
990                *self.flash_debt.entry(in_currency).or_default() += in_amount;
991                Ok(())
992            }
993            // Self-fund seed OR cross-ledger credit (`V4_TAKE_COMPACT(cur→SELF)`):
994            // both credit the executor's `Erc20` balance. No debt, no D0 check.
995            LedgerOp::SelfFund { currency, amount }
996            | LedgerOp::Erc20Credit { currency, amount } => {
997                self.erc20.credit(currency, amount);
998                Ok(())
999            }
1000            // Native pay-in (executor→PM, native settle leg): debit the
1001            // executor's native balance. Requires Native ≥ amount immediately
1002            // before — a `WethWithdraw` or native V4 take must have produced it.
1003            LedgerOp::NativeTransfer { amount } => {
1004                if self.native < amount.cast_signed() {
1005                    return Err(ValidationError::NativeTransferBeforeCredit {
1006                        wanted: amount,
1007                        have: self.native,
1008                    });
1009                }
1010                self.native -= amount.cast_signed();
1011                Ok(())
1012            }
1013            // Unwrap WETH → native: debit Erc20[WETH], credit Native.
1014            LedgerOp::WethWithdraw { weth, amount } => {
1015                self.erc20.debit(weth, amount)?;
1016                self.native += amount.cast_signed();
1017                Ok(())
1018            }
1019            // Wrap native → WETH: debit Native, credit Erc20[WETH].
1020            LedgerOp::WethDeposit { weth, amount } => {
1021                if self.native < amount.cast_signed() {
1022                    return Err(ValidationError::NativeTransferBeforeCredit {
1023                        wanted: amount,
1024                        have: self.native,
1025                    });
1026                }
1027                self.native -= amount.cast_signed();
1028                self.erc20.credit(weth, amount);
1029                Ok(())
1030            }
1031            // Native credit half of V4TakeCompact(native→SELF).
1032            LedgerOp::NativeCredit { amount } => {
1033                self.native += amount.cast_signed();
1034                Ok(())
1035            }
1036            LedgerOp::Erc20Transfer {
1037                currency,
1038                amount,
1039                repays_flash,
1040            } => {
1041                // A flash repayment only debits what is actually still owed
1042                // (`min(amount, debt)`) so the auto-pay-at-callback-end case
1043                // (empty-callback V2/V3 flash, which fires a full `in_amount`
1044                // repayment) zeroes the debt without over-debiting when the
1045                // callback already repaid part. A plain transfer (seed/bridge)
1046                // debits the full amount.
1047                let debit = if repays_flash.is_some() {
1048                    let owed = *self.flash_debt.get(&currency).unwrap_or(&0);
1049                    amount.min(owed)
1050                } else {
1051                    amount
1052                };
1053                // The checked withdrawal (D0 credit-before-debit) routes
1054                // through the `Erc20Ledger::debit` trait method, which emits
1055                // `Erc20TransferBeforeCredit` on insufficient balance.
1056                self.erc20.debit(currency, debit)?;
1057                if repays_flash.is_some() {
1058                    let owed = self.flash_debt.entry(currency).or_default();
1059                    *owed = owed.saturating_sub(debit);
1060                }
1061                Ok(())
1062            }
1063            // External-ledger flash (VIXQYH stub): credit the external
1064            // ledger + incur flash debt — mirrors V2Flash/V3Flash but routed to
1065            // the indexed `ExternalLedger` impl. The additive proof: the D0
1066            // invariant applies to the external ledger via the trait, no new
1067            // grammar shape.
1068            LedgerOp::ExternalFlash {
1069                ledger,
1070                out_currency,
1071                out_amount,
1072                in_currency,
1073                in_amount,
1074            } => {
1075                let ext = self
1076                    .externals
1077                    .get_mut(usize::from(ledger))
1078                    .ok_or(ValidationError::UnknownExternalLedger { ledger })?;
1079                ext.credit(out_currency, out_amount);
1080                *self.flash_debt.entry(in_currency).or_default() += in_amount;
1081                Ok(())
1082            }
1083            // External-ledger repayment: the checked D0 debit on the external
1084            // ledger, then zero the flash debt. Same rule as Erc20Transfer,
1085            // different ledger impl.
1086            LedgerOp::ExternalRepay {
1087                ledger,
1088                currency,
1089                amount,
1090            } => {
1091                let ext = self
1092                    .externals
1093                    .get_mut(usize::from(ledger))
1094                    .ok_or(ValidationError::UnknownExternalLedger { ledger })?;
1095                // A flash repayment only debits what is still owed (mirrors
1096                // Erc20Transfer's `min(amount, debt)` rule).
1097                let owed = *self.flash_debt.get(&currency).unwrap_or(&0);
1098                let debit = amount.min(owed);
1099                ext.debit(currency, debit)?;
1100                let owed = self.flash_debt.entry(currency).or_default();
1101                *owed = owed.saturating_sub(debit);
1102                Ok(())
1103            }
1104        }
1105    }
1106
1107    /// Finish the stream: every flash debt must have been repaid (the V2/V3
1108    /// analogue of the V4 "every delta nets to zero by callback end"
1109    /// invariant). Call after the last `push` (or after `validate`).
1110    ///
1111    /// # Errors
1112    ///
1113    /// Returns [`ValidationError::FlashDebtUnpaid`] if any flash debt is
1114    /// still outstanding.
1115    pub fn finish(&mut self) -> Result<(), ValidationError> {
1116        for (currency, amount) in &self.flash_debt {
1117            if *amount > 0 {
1118                return Err(ValidationError::FlashDebtUnpaid {
1119                    currency: *currency,
1120                    amount: *amount,
1121                });
1122            }
1123        }
1124        Ok(())
1125    }
1126
1127    /// Convenience: validate a whole stream. Stops at the first violation.
1128    /// Does **not** call [`Self::finish`] — call it separately to enforce the
1129    /// flash-debt-net-zero invariant, or use [`Self::validate_full`].
1130    ///
1131    /// # Errors
1132    ///
1133    /// Propagates the first [`ValidationError`] from [`Self::push`].
1134    pub fn validate(&mut self, ops: &[LedgerOp]) -> Result<(), ValidationError> {
1135        for op in ops {
1136            self.push(*op)?;
1137        }
1138        Ok(())
1139    }
1140
1141    /// Validate a whole stream AND assert every flash debt was repaid at the
1142    /// end (the full D4/D5 gate for streams that include V2/V3 flashes).
1143    ///
1144    /// # Errors
1145    ///
1146    /// Returns the first [`ValidationError`] from the stream, or
1147    /// [`ValidationError::FlashDebtUnpaid`] if a flash debt remains after all
1148    /// ops.
1149    pub fn validate_full(&mut self, ops: &[LedgerOp]) -> Result<(), ValidationError> {
1150        self.validate(ops)?;
1151        self.finish()
1152    }
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157    #![expect(clippy::unwrap_used)]
1158    use super::*;
1159    use alloy::primitives::address;
1160
1161    fn weth() -> Address {
1162        address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")
1163    }
1164    fn usdc() -> Address {
1165        address!("A0b86991c6218b36c1D19D4a2e9Eb0cE3606eB48")
1166    }
1167    fn pool() -> Address {
1168        address!("00000000000000000000000000000000000000bb")
1169    }
1170    fn native() -> Address {
1171        Address::ZERO
1172    }
1173
1174    // ── BalanceLedger trait + PmLedger/Erc20Ledger newtypes (ADR-029 D2) ──
1175    // The ledgers are unit-tested in isolation here (the full `LedgerValidator`
1176    // exercises them end-to-end via `push`). These lock the abstraction's
1177    // credit-before-debit semantics directly.
1178
1179    #[test]
1180    fn pm_ledger_credit_debit_balance() {
1181        let mut pm = PmLedger::default();
1182        assert_eq!(pm.balance(usdc()), 0);
1183        pm.credit(usdc(), 1_000);
1184        assert_eq!(pm.balance(usdc()), 1_000);
1185        // Debt allowed — unchecked debit creates negative balance.
1186        pm.debit_debt(usdc(), 1_500);
1187        assert_eq!(pm.balance(usdc()), -500);
1188        // The trait `debit` (checked) on a negative balance fails D0.
1189        assert!(matches!(
1190            pm.debit(usdc(), 1),
1191            Err(ValidationError::TakeBeforeCredit { currency, wanted: 1, have: -500 }) if currency == usdc()
1192        ));
1193    }
1194
1195    // ── TGUZCT/SW42JA: the open-weth batch pairing gate ──
1196    // The 0x43 `V4_BATCH_OPEN_WETH` leaves the positive WETH delta OPEN at
1197    // the batch's end (no tail take). The stream can end the callback only if
1198    // a WETH `V4_MINT_COMPACT` consumes that delta before unlock — otherwise
1199    // the PM's `delta()` settles it to the caller and the stream is wrong
1200    // (the 0x57 settle-all path the flip removes). `OpenWethPairing` arms
1201    // the gate; a mint of the SAME currency before `V4UnlockEnd` disarms it.
1202
1203    fn open_batch_swaps() -> Vec<LedgerOp> {
1204        vec![
1205            LedgerOp::V4Swap {
1206                in_currency: weth(),
1207                in_amount: 100,
1208                out_currency: usdc(),
1209                out_amount: 500,
1210            },
1211            LedgerOp::V4Swap {
1212                in_currency: usdc(),
1213                in_amount: 200,
1214                out_currency: weth(),
1215                out_amount: 300,
1216            },
1217        ]
1218    }
1219
1220    #[test]
1221    fn open_weth_batch_pairs_with_weth_mint_before_unlock_end() {
1222        let mut v = LedgerValidator::default();
1223        for op in open_batch_swaps() {
1224            v.push(op).unwrap();
1225        }
1226        // Post-batch: PM[weth] = +200, PM[usdc] = +300 (open, not tail-taken).
1227        v.push(LedgerOp::OpenWethPairing { weth: weth() }).unwrap();
1228        v.push(LedgerOp::Mint {
1229            currency: weth(),
1230            amount: 200,
1231        })
1232        .unwrap();
1233        v.push(LedgerOp::V4SettleAll).unwrap();
1234        v.push(LedgerOp::V4UnlockEnd).unwrap();
1235    }
1236
1237    #[test]
1238    fn open_weth_batch_without_mint_rejected_at_unlock_end() {
1239        let mut v = LedgerValidator::default();
1240        for op in open_batch_swaps() {
1241            v.push(op).unwrap();
1242        }
1243        v.push(LedgerOp::OpenWethPairing { weth: weth() }).unwrap();
1244        // settle-all hides the delta from the master invariant …
1245        v.push(LedgerOp::V4SettleAll).unwrap();
1246        // … but the pairing gate still fires at unlock end.
1247        let err = v.push(LedgerOp::V4UnlockEnd).unwrap_err();
1248        assert!(
1249            matches!(
1250                err,
1251                ValidationError::OpenWethBatchNotFollowedByMint { weth: c } if c == weth()
1252            ),
1253            "{err:?}"
1254        );
1255    }
1256
1257    #[test]
1258    fn open_weth_batch_not_disarmed_by_foreign_mint() {
1259        let mut v = LedgerValidator::default();
1260        for op in open_batch_swaps() {
1261            v.push(op).unwrap();
1262        }
1263        v.push(LedgerOp::OpenWethPairing { weth: weth() }).unwrap();
1264        // A usdc mint is D0-legal (PM[usdc] = +300) but does NOT satisfy the
1265        // WETH pairing — the WETH delta is still live at unlock end.
1266        v.push(LedgerOp::Mint {
1267            currency: usdc(),
1268            amount: 300,
1269        })
1270        .unwrap();
1271        v.push(LedgerOp::V4SettleAll).unwrap();
1272        let err = v.push(LedgerOp::V4UnlockEnd).unwrap_err();
1273        assert!(
1274            matches!(
1275                err,
1276                ValidationError::OpenWethBatchNotFollowedByMint { weth: c } if c == weth()
1277            ),
1278            "{err:?}"
1279        );
1280    }
1281
1282    #[test]
1283    fn pm_ledger_take_delta_zeros_positive_credit() {
1284        let mut pm = PmLedger::default();
1285        // Before any credit → reject (D0).
1286        assert!(matches!(
1287            pm.take_delta(weth()),
1288            Err(ValidationError::TakeBeforeCredit {
1289                wanted: 1,
1290                have: 0,
1291                ..
1292            })
1293        ));
1294        pm.credit(weth(), 5_000);
1295        assert!(pm.take_delta(weth()).is_ok());
1296        assert_eq!(pm.balance(weth()), 0, "take_delta zeros the whole delta");
1297    }
1298
1299    #[test]
1300    fn pm_ledger_settle_family() {
1301        let mut pm = PmLedger::default();
1302        pm.credit(usdc(), 100);
1303        pm.debit_debt(weth(), 50);
1304        // settle_key zeroes one.
1305        pm.settle_key(usdc());
1306        assert_eq!(pm.balance(usdc()), 0);
1307        assert_eq!(pm.balance(weth()), -50, "settle_key touches only its key");
1308        assert!(matches!(pm.first_nonzero(), Some((c, -50)) if c == weth()));
1309        // settle_all zeroes everything.
1310        pm.settle_all();
1311        assert!(pm.first_nonzero().is_none(), "settle_all clears all deltas");
1312    }
1313
1314    #[test]
1315    fn erc20_ledger_credit_debit_d0() {
1316        let mut e = Erc20Ledger::default();
1317        e.credit(weth(), 1_000);
1318        assert_eq!(e.balance(weth()), 1_000);
1319        // Checked debit succeeds when covered.
1320        assert!(e.debit(weth(), 600).is_ok());
1321        assert_eq!(e.balance(weth()), 400);
1322        // Over-draw fails with Erc20TransferBeforeCredit (D0).
1323        assert!(matches!(
1324            e.debit(weth(), 401),
1325            Err(ValidationError::Erc20TransferBeforeCredit { wanted: 401, have: 400, currency }) if currency == weth()
1326        ));
1327        // The failed debit does not change the balance.
1328        assert_eq!(e.balance(weth()), 400);
1329    }
1330
1331    /// D0 — take-before-credit is rejected (the pre-fix `v2_v2_v4` bug).
1332    #[test]
1333    fn take_before_credit_is_rejected() {
1334        let mut v = LedgerValidator::default();
1335        let err = v.push(LedgerOp::Take {
1336            currency: weth(),
1337            amount: 1_000_000,
1338            repays_flash: None,
1339        });
1340        assert!(matches!(err, Err(ValidationError::TakeBeforeCredit { .. })));
1341    }
1342
1343    /// D0 — a take AFTER a swap that produced the credit is accepted.
1344    #[test]
1345    fn take_after_credit_is_accepted() {
1346        let mut v = LedgerValidator::default();
1347        v.push(LedgerOp::V4Swap {
1348            in_currency: usdc(),
1349            in_amount: 1_000_000,
1350            out_currency: weth(),
1351            out_amount: 2_000_000,
1352        })
1353        .unwrap();
1354        assert!(v
1355            .push(LedgerOp::Take {
1356                currency: weth(),
1357                amount: 1_000_000,
1358                repays_flash: None,
1359            })
1360            .is_ok());
1361    }
1362
1363    /// D0 — the pre-fix `v2_v2_v4` stream (take-WETH before any V4 swap creates
1364    /// a positive WETH delta) is rejected end-to-end.
1365    #[test]
1366    fn prefixed_v2_v2_v4_take_before_swap_rejected() {
1367        let mut v = LedgerValidator::default();
1368        let stream = [
1369            // The bug: an early take of WETH with no prior PM[WETH] credit.
1370            LedgerOp::Take {
1371                currency: weth(),
1372                amount: 100_000,
1373                repays_flash: None,
1374            },
1375            LedgerOp::V4Swap {
1376                in_currency: usdc(),
1377                in_amount: 300_000,
1378                out_currency: weth(),
1379                out_amount: 300_000,
1380            },
1381        ];
1382        assert_eq!(
1383            v.validate(&stream),
1384            Err(ValidationError::TakeBeforeCredit {
1385                currency: weth(),
1386                wanted: 100_000,
1387                have: 0,
1388            })
1389        );
1390    }
1391
1392    /// terminal-V2 — a `V2_SWAP_CALC` with an un-seeded pair is rejected
1393    /// (the `2PT5HH` / `path-182449` über-draw class).
1394    #[test]
1395    fn swap_calc_without_seeded_pair_rejected() {
1396        let mut v = LedgerValidator::default();
1397        assert_eq!(
1398            v.push(LedgerOp::SwapCalc {
1399                pool: pool(),
1400                amount_in: 10_000,
1401                out_currency: weth(),
1402                out_amount: 0,
1403                recipient: SwapRecipient::Executor,
1404            }),
1405            Err(ValidationError::SwapCalcBeforeCredit { pool: pool() })
1406        );
1407    }
1408
1409    /// terminal-V2 — seed-then-`V2_SWAP_CALC` is the accepted ordering.
1410    #[test]
1411    fn seed_then_swap_calc_accepted() {
1412        let mut v = LedgerValidator::default();
1413        v.push(LedgerOp::SeedPair {
1414            pool: pool(),
1415            amount: 10_000,
1416        })
1417        .unwrap();
1418        assert!(v
1419            .push(LedgerOp::SwapCalc {
1420                pool: pool(),
1421                amount_in: 10_000,
1422                out_currency: weth(),
1423                out_amount: 0,
1424                recipient: SwapRecipient::Executor,
1425            })
1426            .is_ok());
1427    }
1428
1429    /// A corrected `v2_v2_v4` ordering (V4 swap produces the WETH delta, THEN
1430    /// the take) validates clean.
1431    #[test]
1432    fn corrected_v4_swap_then_take_accepted() {
1433        let mut v = LedgerValidator::default();
1434        let stream = [
1435            LedgerOp::V4Swap {
1436                in_currency: weth(),
1437                in_amount: 200_000,
1438                out_currency: usdc(),
1439                out_amount: 200_000,
1440            },
1441            LedgerOp::V4Swap {
1442                in_currency: usdc(),
1443                in_amount: 300_000,
1444                out_currency: weth(),
1445                out_amount: 300_000,
1446            },
1447            LedgerOp::Take {
1448                currency: weth(),
1449                amount: 100_000,
1450                repays_flash: None,
1451            },
1452        ];
1453        assert!(v.validate(&stream).is_ok());
1454    }
1455
1456    // ════════════════════════════════════════════════════════════════════
1457    // POC (6SRC23): the V2/V3 flash-credit chain for `v2_v3` (InPathFlash).
1458    // The executor starts at 0; a flash repayment (ERC20_TRANSFER from the
1459    // executor) is only legal AFTER the flash that extended that currency's
1460    // credit. Byte-parity cannot see this ordering defect; the gate can.
1461    // ════════════════════════════════════════════════════════════════════
1462
1463    /// The canonical `v2_v3` (InPathFlash) trace in stream order — the same
1464    /// ordering `derive_2hop_v2v3_trace` (grammar_shape) will emit. The V2
1465    /// flash credits t1 (forward); the V3 flash credits WETH (terminal); each
1466    /// flash's repayment consumes the credit the OTHER flash extended, in
1467    /// order. Must validate clean.
1468    #[test]
1469    fn v2_v3_flash_chain_accepted() {
1470        let mut v = LedgerValidator::default();
1471        // 1. V2 flash: credit t1, owe WETH.
1472        v.push(LedgerOp::V2Flash {
1473            out_currency: usdc(),
1474            out_amount: 1_000_000,
1475            in_currency: weth(),
1476            in_amount: 900_000,
1477            recipient: SwapRecipient::Executor,
1478        })
1479        .unwrap();
1480        // 2. V3 flash: credit WETH, owe t1.
1481        v.push(LedgerOp::V3Flash {
1482            out_currency: weth(),
1483            out_amount: 1_200_000,
1484            in_currency: usdc(),
1485            in_amount: 1_000_000,
1486            recipient: SwapRecipient::Executor,
1487        })
1488        .unwrap();
1489        // 3. Repay the V3 flash (t1) — credit extended by op 1.
1490        v.push(LedgerOp::Erc20Transfer {
1491            currency: usdc(),
1492            amount: 1_000_000,
1493            repays_flash: Some(pool()),
1494        })
1495        .unwrap();
1496        // 4. Repay the V2 flash (WETH) — credit extended by op 2.
1497        v.push(LedgerOp::Erc20Transfer {
1498            currency: weth(),
1499            amount: 900_000,
1500            repays_flash: Some(pool()),
1501        })
1502        .unwrap();
1503        assert!(v.finish().is_ok(), "fully-repaid flash chain must validate");
1504    }
1505
1506    /// Misordered `v2_v3`: the WETH flash repayment (op 4) is hoisted BEFORE
1507    /// the V3 flash (op 2) extends WETH credit. The executor's WETH balance is
1508    /// still 0 at that point → rejected. This is the structural defect the
1509    /// runtime matrix cannot see (the bytes would revert on-chain with an
1510    /// opaque transfer-revert; the gate names the invariant).
1511    #[test]
1512    fn v2_v3_flash_repay_before_credit_rejected() {
1513        let mut v = LedgerValidator::default();
1514        v.push(LedgerOp::V2Flash {
1515            out_currency: usdc(),
1516            out_amount: 1_000_000,
1517            in_currency: weth(),
1518            in_amount: 900_000,
1519            recipient: SwapRecipient::Executor,
1520        })
1521        .unwrap();
1522        // BUG: repay the V2 flash (WETH) BEFORE the V3 flash credits WETH.
1523        assert_eq!(
1524            v.push(LedgerOp::Erc20Transfer {
1525                currency: weth(),
1526                amount: 900_000,
1527                repays_flash: Some(pool()),
1528            }),
1529            Err(ValidationError::Erc20TransferBeforeCredit {
1530                currency: weth(),
1531                wanted: 900_000,
1532                have: 0,
1533            })
1534        );
1535    }
1536
1537    /// An underpaid flash debt is rejected at `finish()` (the V2/V3 analogue of
1538    /// the V4 "every delta nets to zero by callback end" invariant).
1539    #[test]
1540    fn underpaid_flash_debt_rejected_at_finish() {
1541        let mut v = LedgerValidator::default();
1542        v.push(LedgerOp::V2Flash {
1543            out_currency: usdc(),
1544            out_amount: 1_000_000,
1545            in_currency: weth(),
1546            in_amount: 900_000,
1547            recipient: SwapRecipient::Executor,
1548        })
1549        .unwrap();
1550        v.push(LedgerOp::V3Flash {
1551            out_currency: weth(),
1552            out_amount: 1_200_000,
1553            in_currency: usdc(),
1554            in_amount: 1_000_000,
1555            recipient: SwapRecipient::Executor,
1556        })
1557        .unwrap();
1558        // Repay V3 fully, but "forget" to repay the V2 flash's WETH debt.
1559        v.push(LedgerOp::Erc20Transfer {
1560            currency: usdc(),
1561            amount: 1_000_000,
1562            repays_flash: Some(pool()),
1563        })
1564        .unwrap();
1565        assert!(matches!(
1566            v.finish(),
1567            Err(ValidationError::FlashDebtUnpaid {
1568                currency, amount
1569            }) if currency == weth() && amount == 900_000
1570        ));
1571    }
1572
1573    // ═══════════════════════════════════════════════════════════════════
1574    // BP7KIR Increment 3b: the `v4_v3` cross-ledger boundary take. The V4
1575    // swap credits PM[t1]; `V4TakeCompact(t1→SELF)` debits PM[t1] AND credits
1576    // the executor `Erc20[t1]` (the token physically arrives); the V3 flash's
1577    // auto-repay then debits that `Erc20[t1]`. The gate enforces the boundary
1578    // ordering — the structural defect byte-parity cannot see.
1579    // ═══════════════════════════════════════════════════════════════════
1580
1581    /// The canonical `v4_v3` ledger trace in stream order validates clean: the
1582    /// boundary take credits `Erc20[t1]` before the V3 auto-repay debits it, PM
1583    /// nets to zero (`V4UnlockEnd`), and the V3 flash debt is repaid.
1584    #[test]
1585    fn v4_v3_boundary_take_chain_accepted() {
1586        let mut v = LedgerValidator::default();
1587        // V4 swap a: PM[WETH] −= optimal_input, PM[t1] += forward_out.
1588        v.push(LedgerOp::V4Swap {
1589            in_currency: weth(),
1590            in_amount: 100_000,
1591            out_currency: usdc(),
1592            out_amount: 110_000,
1593        })
1594        .unwrap();
1595        // Boundary take (→SELF): PM[t1] −= forward_out; Erc20[t1] += forward_out.
1596        v.push(LedgerOp::Take {
1597            currency: usdc(),
1598            amount: 110_000,
1599            repays_flash: None,
1600        })
1601        .unwrap();
1602        v.push(LedgerOp::Erc20Credit {
1603            currency: usdc(),
1604            amount: 110_000,
1605        })
1606        .unwrap();
1607        // Terminal V3 flash: credits WETH (profit), owes t1 — auto-repaid from
1608        // the Erc20[t1] credit the boundary take created.
1609        v.push(LedgerOp::V3Flash {
1610            out_currency: weth(),
1611            out_amount: 120_000,
1612            in_currency: usdc(),
1613            in_amount: 110_000,
1614            recipient: SwapRecipient::Executor,
1615        })
1616        .unwrap();
1617        // Auto-repay (empty callback): debits min(110_000, 110_000) of t1.
1618        v.push(LedgerOp::Erc20Transfer {
1619            currency: usdc(),
1620            amount: 110_000,
1621            repays_flash: Some(pool()),
1622        })
1623        .unwrap();
1624        // Settle the V4 input debt + residual, then the net-zero assertion.
1625        v.push(LedgerOp::V4SettleDelta { currency: weth() })
1626            .unwrap();
1627        v.push(LedgerOp::V4SettleAll).unwrap();
1628        v.push(LedgerOp::V4UnlockEnd).unwrap();
1629        assert!(
1630            v.finish().is_ok(),
1631            "v4_v3 boundary-take chain must validate"
1632        );
1633    }
1634
1635    /// The structural defect: the boundary `Erc20Credit` (the V4 take's
1636    /// recipient-side) is omitted, so the V3 auto-repay fires against
1637    /// `Erc20[t1] == 0` → rejected. This is the cross-ledger analogue of D0 —
1638    /// the outside-ledger consume must follow the V4 take that funds it. A
1639    /// byte-parity check cannot see this (the bytes would revert on-chain with
1640    /// an opaque transfer-revert); the gate names the invariant.
1641    #[test]
1642    fn v4_v3_boundary_take_omitted_rejected() {
1643        let mut v = LedgerValidator::default();
1644        v.push(LedgerOp::V4Swap {
1645            in_currency: weth(),
1646            in_amount: 100_000,
1647            out_currency: usdc(),
1648            out_amount: 110_000,
1649        })
1650        .unwrap();
1651        // BUG: the `V4TakeCompact`'s Erc20Credit half is missing — the take
1652        // debited PM[t1] but never credited the executor's Erc20[t1].
1653        v.push(LedgerOp::V3Flash {
1654            out_currency: weth(),
1655            out_amount: 120_000,
1656            in_currency: usdc(),
1657            in_amount: 110_000,
1658            recipient: SwapRecipient::Executor,
1659        })
1660        .unwrap();
1661        assert_eq!(
1662            v.push(LedgerOp::Erc20Transfer {
1663                currency: usdc(),
1664                amount: 110_000,
1665                repays_flash: Some(pool()),
1666            }),
1667            Err(ValidationError::Erc20TransferBeforeCredit {
1668                currency: usdc(),
1669                wanted: 110_000,
1670                have: 0,
1671            })
1672        );
1673    }
1674
1675    // ══════════════════════════════════════════════════════════════════
1676    // BP7KIR Increment 3b: the `v4_v2` boundary-seed family. The V4 forward
1677    // output is taken DIRECTLY to the V2 pair (PM→pool via `SeedPair`),
1678    // consumed by a `V2SwapCalc` (2PT5HH across the PM boundary); the V4
1679    // WETH-input debt is settled by `Erc20Transfer(WETH→PM)` + `V4Settle`,
1680    // funded by the V2 swap's WETH output credit.
1681    // ══════════════════════════════════════════════════════════════════
1682
1683    /// The canonical `v4_v2` ledger trace validates clean: the boundary take
1684    /// seeds the pair before the `V2SwapCalc` consumes it, and the V2 WETH
1685    /// output credit precedes the PM pay-in (`Erc20Transfer→PM`). PM nets to
1686    /// zero (`V4UnlockEnd`) and the profit remains in `Erc20[WETH]`.
1687    #[test]
1688    fn v4_v2_boundary_seed_chain_accepted() {
1689        let mut v = LedgerValidator::default();
1690        // V4 swap a: PM[WETH] −= optimal_input, PM[t1] += forward_out.
1691        v.push(LedgerOp::V4Swap {
1692            in_currency: weth(),
1693            in_amount: 100_000,
1694            out_currency: usdc(),
1695            out_amount: 110_000,
1696        })
1697        .unwrap();
1698        // Boundary take → V2 pair: PM[t1] −= forward_out; SeedPair(v2, forward_out).
1699        v.push(LedgerOp::Take {
1700            currency: usdc(),
1701            amount: 110_000,
1702            repays_flash: None,
1703        })
1704        .unwrap();
1705        v.push(LedgerOp::SeedPair {
1706            pool: pool(),
1707            amount: 110_000,
1708        })
1709        .unwrap();
1710        // Terminal V2 SwapCalc: consumes the seeded pair, credits Erc20[WETH].
1711        v.push(LedgerOp::SwapCalc {
1712            pool: pool(),
1713            amount_in: 0,
1714            out_currency: weth(),
1715            out_amount: 120_000,
1716            recipient: SwapRecipient::Executor,
1717        })
1718        .unwrap();
1719        // Boundary-seed: pay WETH into the PM from the V2 output, settle the
1720        // V4 input debt (V4Sync is delta-neutral — modeled here by the
1721        // Erc20Transfer debit + V4Settle credit pair).
1722        v.push(LedgerOp::Erc20Transfer {
1723            currency: weth(),
1724            amount: 100_000,
1725            repays_flash: None,
1726        })
1727        .unwrap();
1728        v.push(LedgerOp::V4Settle {
1729            currency: weth(),
1730            amount: 100_000,
1731        })
1732        .unwrap();
1733        v.push(LedgerOp::V4SettleAll).unwrap();
1734        v.push(LedgerOp::V4UnlockEnd).unwrap();
1735        assert!(
1736            v.finish().is_ok(),
1737            "v4_v2 boundary-seed chain must validate"
1738        );
1739    }
1740
1741    /// The structural defect: the boundary take+seed is omitted, so the
1742    /// `V2SwapCalc` fires against `pair[v2] == 0` → rejected (the 2PT5HH
1743    // terminal-V2 rule across the PM boundary).
1744    #[test]
1745    fn v4_v2_pair_seed_omitted_rejected() {
1746        let mut v = LedgerValidator::default();
1747        v.push(LedgerOp::V4Swap {
1748            in_currency: weth(),
1749            in_amount: 100_000,
1750            out_currency: usdc(),
1751            out_amount: 110_000,
1752        })
1753        .unwrap();
1754        // BUG: the V4TakeCompact(→v2 pair, SeedPair) is missing — the pair is
1755        // never seeded.
1756        assert_eq!(
1757            v.push(LedgerOp::SwapCalc {
1758                pool: pool(),
1759                amount_in: 0,
1760                out_currency: weth(),
1761                out_amount: 120_000,
1762                recipient: SwapRecipient::Executor,
1763            }),
1764            Err(ValidationError::SwapCalcBeforeCredit { pool: pool() })
1765        );
1766    }
1767
1768    /// The cross-ledger defect: the PM pay-in (`Erc20Transfer(WETH→PM)`) is
1769    /// hoisted before the `V2SwapCalc` that credits `Erc20[WETH]`. The
1770    /// executor's WETH balance is still 0 → rejected (the outside→PM settle
1771    /// must follow the V2 output that funds it).
1772    #[test]
1773    fn v4_v2_pm_payin_before_v2_output_rejected() {
1774        let mut v = LedgerValidator::default();
1775        v.push(LedgerOp::V4Swap {
1776            in_currency: weth(),
1777            in_amount: 100_000,
1778            out_currency: usdc(),
1779            out_amount: 110_000,
1780        })
1781        .unwrap();
1782        v.push(LedgerOp::Take {
1783            currency: usdc(),
1784            amount: 110_000,
1785            repays_flash: None,
1786        })
1787        .unwrap();
1788        v.push(LedgerOp::SeedPair {
1789            pool: pool(),
1790            amount: 110_000,
1791        })
1792        .unwrap();
1793        // BUG: pay WETH into the PM BEFORE the V2SwapCalc credits Erc20[WETH].
1794        assert_eq!(
1795            v.push(LedgerOp::Erc20Transfer {
1796                currency: weth(),
1797                amount: 100_000,
1798                repays_flash: None,
1799            }),
1800            Err(ValidationError::Erc20TransferBeforeCredit {
1801                currency: weth(),
1802                wanted: 100_000,
1803                have: 0,
1804            })
1805        );
1806    }
1807
1808    // ══════════════════════════════════════════════════════════════════
1809    // BP7KIR Increment 3c: native settle. The V4 native-input debt (PM[native])
1810    // is settled by `WethWithdraw` (credit Native) + `NativeTransfer` (debit
1811    // Native → PM) + `V4SettleDelta(native)` (zero PM[native]). The
1812    // `NativeTransfer` is the executor-debit half, separate from the
1813    // `SettleDelta` PM-credit half — the gate's core value.
1814    // ══════════════════════════════════════════════════════════════════
1815
1816    /// The native settle chain validates clean: the WethWithdraw credits
1817    /// Native before the NativeTransfer debits it, and PM[native] nets to zero.
1818    #[test]
1819    fn native_settle_chain_accepted() {
1820        let mut v = LedgerValidator::default();
1821        // V4 swap with native input: PM[native] −= debt, PM[t1] += forward_out.
1822        v.push(LedgerOp::V4Swap {
1823            in_currency: native(),
1824            in_amount: 100_000,
1825            out_currency: usdc(),
1826            out_amount: 110_000,
1827        })
1828        .unwrap();
1829        // (forward output handling elided — focus on the native settle.)
1830        // Seed the executor's WETH (the source of the unwrapped native).
1831        v.push(LedgerOp::Erc20Credit {
1832            currency: weth(),
1833            amount: 100_000,
1834        })
1835        .unwrap();
1836        // Unwrap WETH → native (credits Native).
1837        v.push(LedgerOp::WethWithdraw {
1838            weth: weth(),
1839            amount: 100_000,
1840        })
1841        .unwrap();
1842        // Native pay-in (debit Native) + settle (zero PM[native]).
1843        v.push(LedgerOp::NativeTransfer { amount: 100_000 })
1844            .unwrap();
1845        v.push(LedgerOp::V4SettleDelta { currency: native() })
1846            .unwrap();
1847        v.push(LedgerOp::V4SettleAll).unwrap();
1848        v.push(LedgerOp::V4UnlockEnd).unwrap();
1849        assert!(v.finish().is_ok(), "native settle chain must validate");
1850    }
1851
1852    /// The structural defect: the `NativeTransfer` (native pay-in) fires BEFORE
1853    /// the `WethWithdraw` that produces the native — the executor's Native
1854    /// balance is 0 → rejected (the native analogue of D0). Byte-parity cannot
1855    /// see this ordering (the bytes would revert on-chain with an opaque
1856    /// native-settle revert); the gate names the invariant.
1857    #[test]
1858    fn native_transfer_before_credit_rejected() {
1859        let mut v = LedgerValidator::default();
1860        v.push(LedgerOp::V4Swap {
1861            in_currency: native(),
1862            in_amount: 100_000,
1863            out_currency: usdc(),
1864            out_amount: 110_000,
1865        })
1866        .unwrap();
1867        v.push(LedgerOp::Erc20Credit {
1868            currency: weth(),
1869            amount: 100_000,
1870        })
1871        .unwrap();
1872        // BUG: native pay-in BEFORE the WethWithdraw credits Native.
1873        assert_eq!(
1874            v.push(LedgerOp::NativeTransfer { amount: 100_000 }),
1875            Err(ValidationError::NativeTransferBeforeCredit {
1876                wanted: 100_000,
1877                have: 0,
1878            })
1879        );
1880    }
1881
1882    // ═══════════════════════════════════════════════════════════════════
1883    // VIXQYH — additive-capability proof (ADR-029 D6)
1884    // ═══════════════════════════════════════════════════════════════════
1885    // A stub external held-balance ledger (Balancer-Vault / Aave-lender shape)
1886    // composes with the existing protocols as ONE new `BalanceLedger` impl +
1887    // two new `LedgerOp` variants — NOT a new adapter per cell of (protocol ×
1888    // position × neighbor × funding × capture). The D0 + flash-debt-net-zero
1889    // invariants apply to it uniformly via the trait.
1890
1891    #[test]
1892    fn external_flash_composes_with_v4_and_validates() {
1893        // Representative row: an external-ledger flash funds a V4 swap, the
1894        // V4 output repays the external flash. The Plan tree:
1895        //   ExternalFlash(ledger=0, out=weth, in=weth)   — flash extends credit
1896        //   V4Swap(weth→usdc)                            — PM[weth]−, PM[usdc]+
1897        //   V4TakeDelta(usdc)                            — profit capture
1898        //   V4SettleAll                                  — net-zero
1899        //   ExternalRepay(ledger=0, weth)                — repay the flash
1900        // The validator must accept this (the external ledger + the PM both
1901        // satisfy their invariants; the flash debt is repaid).
1902        let mut v =
1903            LedgerValidator::default().with_external_ledgers(vec![ExternalLedger::default()]);
1904        let stream = [
1905            LedgerOp::ExternalFlash {
1906                ledger: 0,
1907                out_currency: weth(),
1908                out_amount: 1_000_000,
1909                in_currency: weth(),
1910                in_amount: 1_000_000,
1911            },
1912            LedgerOp::V4Swap {
1913                in_currency: weth(),
1914                in_amount: 1_000_000,
1915                out_currency: usdc(),
1916                out_amount: 1_100_000,
1917            },
1918            // Capture the usdc profit (zeroes PM[usdc]).
1919            LedgerOp::Take {
1920                currency: usdc(),
1921                amount: 1_100_000,
1922                repays_flash: None,
1923            },
1924            // The V4 input debt: PM[weth] is −1_000_000 (the swap debited it).
1925            // Settle it (the executor's held weth — credited by the flash —
1926            // covers it via a V4Settle credit).
1927            LedgerOp::V4Settle {
1928                currency: weth(),
1929                amount: 1_000_000,
1930            },
1931            LedgerOp::V4UnlockEnd,
1932            // Now repay the external flash from the executor's weth balance.
1933            LedgerOp::ExternalRepay {
1934                ledger: 0,
1935                currency: weth(),
1936                amount: 1_000_000,
1937            },
1938        ];
1939        let result = v.validate_full(&stream);
1940        assert!(
1941            result.is_ok(),
1942            "external-ledger flash + V4 swap + repay must validate clean: {result:?}"
1943        );
1944    }
1945
1946    #[test]
1947    fn external_ledger_debit_before_flash_is_noop_not_overdrawn() {
1948        // `ExternalRepay` debits `min(amount, owed)` (mirrors Erc20Transfer's
1949        // flash-repay rule) — so a repay with no outstanding flash debt debits
1950        // 0 (a no-op), NOT an over-draw. Confirms the external ledger inherits
1951        // the same saturating-repay semantics as the executor ERC-20 ledger.
1952        let mut v =
1953            LedgerValidator::default().with_external_ledgers(vec![ExternalLedger::default()]);
1954        assert!(
1955            v.push(LedgerOp::ExternalRepay {
1956                ledger: 0,
1957                currency: weth(),
1958                amount: 1_000_000,
1959            })
1960            .is_ok(),
1961            "external repay with no flash debt debits 0 (min rule)"
1962        );
1963        // And the external-ledger balance is unchanged (no negative balance).
1964        assert_eq!(v.externals[0].balance(weth()), 0);
1965    }
1966
1967    #[test]
1968    fn external_ledger_debit_d0_enforced_directly() {
1969        // The `BalanceLedger::debit` D0 check on the external ledger — directly
1970        // unit-tested (the validator routes ExternalRepay through this). A debit
1971        // exceeding the held balance is rejected with the same error shape as
1972        // the executor ERC-20 ledger.
1973        let mut ext = ExternalLedger::default();
1974        ext.credit(weth(), 500);
1975        assert!(matches!(
1976            ext.debit(weth(), 501),
1977            Err(ValidationError::Erc20TransferBeforeCredit { wanted: 501, have: 500, currency })
1978                if currency == weth()
1979        ));
1980        assert_eq!(
1981            ext.balance(weth()),
1982            500,
1983            "failed debit must not change balance"
1984        );
1985    }
1986
1987    #[test]
1988    fn external_flash_unpaid_is_rejected_at_finish() {
1989        // The flash-debt-net-zero invariant applies to external-ledger flashes
1990        // too — an unrepaid ExternalFlash must fail at `finish()`.
1991        let mut v =
1992            LedgerValidator::default().with_external_ledgers(vec![ExternalLedger::default()]);
1993        v.push(LedgerOp::ExternalFlash {
1994            ledger: 0,
1995            out_currency: weth(),
1996            out_amount: 1_000_000,
1997            in_currency: weth(),
1998            in_amount: 1_000_000,
1999        })
2000        .unwrap();
2001        assert!(matches!(
2002            v.finish(),
2003            Err(ValidationError::FlashDebtUnpaid { currency, amount: 1_000_000 })
2004                if currency == weth()
2005        ));
2006    }
2007
2008    #[test]
2009    fn external_flash_unknown_ledger_index_is_rejected() {
2010        // Referencing an unregistered external-ledger index is a config error,
2011        // caught as `UnknownExternalLedger` (the validator was not constructed
2012        // with `with_external_ledgers` covering the index).
2013        let mut v = LedgerValidator::default(); // no externals registered
2014        assert!(matches!(
2015            v.push(LedgerOp::ExternalFlash {
2016                ledger: 0,
2017                out_currency: weth(),
2018                out_amount: 1,
2019                in_currency: weth(),
2020                in_amount: 1,
2021            }),
2022            Err(ValidationError::UnknownExternalLedger { ledger: 0 })
2023        ));
2024    }
2025
2026    #[test]
2027    fn additive_proof_two_external_ledgers_compose_independently() {
2028        // The open set is genuinely open: two distinct external ledgers (a
2029        // Vault at index 0 + a lender at index 1) flash independently and both
2030        // invariants fire per-ledger. This is the structure a real Balancer +
2031        // Aave integration plugs into — no new grammar shape, just two more
2032        // `BalanceLedger` impls behind the same interface.
2033        let mut v = LedgerValidator::default()
2034            .with_external_ledgers(vec![ExternalLedger::default(), ExternalLedger::default()]);
2035        let stream = [
2036            // Vault (idx 0) flashes WETH.
2037            LedgerOp::ExternalFlash {
2038                ledger: 0,
2039                out_currency: weth(),
2040                out_amount: 1_000_000,
2041                in_currency: weth(),
2042                in_amount: 1_000_000,
2043            },
2044            // Lender (idx 1) flashes USDC.
2045            LedgerOp::ExternalFlash {
2046                ledger: 1,
2047                out_currency: usdc(),
2048                out_amount: 500_000,
2049                in_currency: usdc(),
2050                in_amount: 500_000,
2051            },
2052            // Repay both.
2053            LedgerOp::ExternalRepay {
2054                ledger: 1,
2055                currency: usdc(),
2056                amount: 500_000,
2057            },
2058            LedgerOp::ExternalRepay {
2059                ledger: 0,
2060                currency: weth(),
2061                amount: 1_000_000,
2062            },
2063        ];
2064        assert!(
2065            v.validate_full(&stream).is_ok(),
2066            "two external ledgers must compose independently"
2067        );
2068    }
2069
2070    /// VIXQYH acceptance: quantify the combinatorial savings. Under the old
2071    /// bespoke-adapter model (pre-ADR-029), adding a 4th protocol family as a
2072    /// new axis value would have forced a new hand-written adapter for every
2073    /// (position × neighbor × funding × capture) cell the new protocol touches.
2074    /// This test makes that fan-out explicit and asserts the additive model
2075    /// absorbs it as ONE `BalanceLedger` impl + the two `LedgerOp` variants
2076    // already added above.
2077    #[test]
2078    fn additive_model_avoids_combinatorial_fanout() {
2079        // The old model's would-be adapter count: a 4th protocol (P4) composed
2080        // across the existing Uniswap-family 2-hop + 3-hop matrix. Per ADR-029
2081        // D6, the bespoke-adapter disease fans out over (position × neighbor ×
2082        // funding × capture). The existing matrix dimensions:
2083        let protocols = 4; // V2, V3, V4, + the new P4
2084        let funding_sources = 3; // SelfFund, InPathFlash, ExternalLender
2085        let capture_modes = 2; // Custody, BalancerVault
2086        let positions = 3; // lead / mid / terminal in a 3-hop
2087        let neighbors = protocols - 1; // each position borders up to (n-1) others
2088                                       // The full combinatorial: every cell where P4 appears in some position
2089                                       // with some neighbor, × funding × capture.
2090        let old_adapters = positions * neighbors * funding_sources * capture_modes;
2091        // The additive model: one `BalanceLedger` impl (ExternalLedger) + the
2092        // `ExternalFlash`/`ExternalRepay` LedgerOp variants. Count the concrete
2093        // additions this epic made for the external-ledger axis value:
2094        let additive_additions = 1; // one BalanceLedger impl (ExternalLedger)
2095                                    // + 2 LedgerOp variants, counted as one axis-value bundle
2096        assert_eq!(
2097            old_adapters, 54,
2098            "sanity: the old model's would-be adapter count for a 4th protocol \n(positions × neighbors × funding × capture = 3×3×3×2)"
2099        );
2100        assert!(
2101            additive_additions < old_adapters,
2102            "additive model ({additive_additions} impl) vs old model ({old_adapters} adapters): \nn0 combinatorial fan-out"
2103        );
2104        // The concrete proof: the external-ledger Validate tests above pass
2105        // against the EXISTING V4Swap/V4TakeDelta/V4SettleAll ops — the new
2106        // ledger composed across the existing protocol shape without a new
2107        // per-cell adapter.
2108        let _ = (
2109            protocols,
2110            funding_sources,
2111            capture_modes,
2112            positions,
2113            neighbors,
2114        );
2115    }
2116}