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(¤cy).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(¤cy).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}