diff --git a/.claude/lez-fees/ANALYSIS.md b/.claude/lez-fees/ANALYSIS.md new file mode 100644 index 000000000..d2b709d71 --- /dev/null +++ b/.claude/lez-fees/ANALYSIS.md @@ -0,0 +1,309 @@ +# LEZ Fee Subsystem — Spec-to-Codebase Analysis + +Phase-1 analysis for implementing `.claude/lez-fees/SPECS.md`. Maps every spec +requirement onto the current code, lists gaps and contradictions, and proposes a +workstream decomposition. File:line anchors are as of `dev` @ 87fca2a1. + +--- + +## 1. Where things live today + +### 1.1 Transactions and wire format + +- `common::transaction::LeeTransaction` (`lez/common/src/transaction.rs:10`) is the + block-level tx enum with **three** variants: `Public`, `PrivacyPreserving`, + `ProgramDeployment`. The spec covers only public and private — **program + deployment is unpriced** (see Gap G8). +- `lee::PublicTransaction` (`lee/state_machine/src/public_transaction/transaction.rs:10`) + = `Message { program_id, account_ids, nonces, instruction_data }` + `WitnessSet` + (sig+pubkey pairs). **No fee fields exist**: no `payer`, `gas_limit`, `tip`, + `max_fee`. Signers are derived from the witness set; there is no distinguished + fee-payer. +- `lee::PrivacyPreservingTransaction` + (`lee/state_machine/src/privacy_preserving_transaction/`) = `Message { + public_actions, nonces, private_actions, validity windows }` + `WitnessSet { + signatures_and_public_keys, proof }`. Every field is a variable-length Vec and + the proof is a Borsh-serialized Risc0 `InnerReceipt` + (`circuit/mod.rs:21-41`) — **the private wire size is nowhere near constant + today** (see Gap G3). +- Tx hashing is `SHA256(borsh(tx))` with domain prefixes + (`public_transaction/message.rs:11`, block prefix `common/src/block.rs:117`). + Adding fee fields **changes tx hashes, block hashes, and signatures** — + a hard wire/protocol break, gated by the `/LEE/v0.3/` version prefixes. +- `data_bytes` would be `borsh::to_vec(&LeeTransaction).len()` as embedded in + `BlockBody.transactions` — this is exactly what the block-size accounting in + `build_block_from_mempool` measures today (`lez/sequencer/core/src/lib.rs:878`). + +### 1.2 Block structure and production + +- `common::block::Block` (`lez/common/src/block.rs:61`): header `{ block_id, + prev_block_hash, hash, timestamp, signature }` + body (tx vec) + + `bedrock_status`. **No producer identity in the header** — only an Ed25519 + signature, which is *not checked at all* in the shared apply path + (`lez/chain_state/src/apply.rs:82` checks only hash/id/parent linkage), and is + not pubkey-recoverable (see Gap G6). +- Producer loop: `SequencerCore::build_block_from_mempool` + (`lez/sequencer/core/src/lib.rs:727`). Pops mempool txs FIFO + (`lez/mempool/src/lib.rs` — a plain tokio channel, no fee ordering), validates + each on a working clone of head state, **skips failures** (`lib.rs:939-951`, + failed txs are simply not included), enforces `max_block_size` (default 1 MiB, + `config.rs:98`) by serializing the whole candidate block, and + `max_num_tx_in_block`. Every block ends with a **mandatory clock tx** — + a canonical, *unsigned* public tx (`common/src/transaction.rs:233`, + enforced by equality at `chain_state/src/apply.rs:132-137`). +- Sequencer-originated txs besides clock: bridge-deposit mints and cross-zone + dispatches, drained from the store before user txs (`lib.rs:824-861`), executed + via `transition_from_public_transaction` (bypasses system-account guards); the + clock tx is likewise system-generated. **None of these has a payer** (Gap G7). + +### 1.3 Validation / state transition + +- Shared validate-then-apply entry point: + `chain_state::apply::apply_block_to_state` (`lez/chain_state/src/apply.rs:125`) + — used by the sequencer follow path, reconstruction, and (via + `accept_block`) the indexer. This is the natural home of the spec's + `block_transition`. +- **Any tx failure currently rejects the whole block** (`apply.rs:157-161`). + The spec instead requires tx-level failures to keep the fee and discard + effects while the block stays valid (Gap G5). +- Per-tx execution: `LeeTransaction::validate_on_state` / `execute_on_state` + (`lez/common/src/transaction.rs:84,145`) → + `ValidatedStateDiff::from_*_transaction` + (`lee/state_machine/src/validated_state_diff/mod.rs:57,330,441`). The + diff-then-apply structure already gives the spec's "transaction-local + checkpoint" for free: a failing tx produces no diff, state untouched. What's + missing is *charging the fee anyway*. +- Indexer replay path trusts inscriptions: `execute_on_state` skips the + system-account guards (`transaction.rs:145-156`). Fee logic must live below + that split so sequencer and indexer produce identical fee state. + +### 1.4 Execution & metering (Risc0) + +- Public execution *is* zkVM execution (no proving): + `Program::execute` (`lee/state_machine/src/program/mod.rs:55-86`) runs + `default_executor().execute(env, elf)` with + `session_limit(32M)` (`mod.rs:15`, marked `TODO: Make this variable when fees + are implemented`). +- **Metered user cycles are available**: `SessionInfo::cycles()` sums per-segment + user cycles (risc0-zkvm 3.0.5, `host/api/mod.rs:405`) — deterministic, + unaffected by `RISC0_DEV_MODE` (confirmed by + `tools/cycle_bench/src/main.rs` header comment). Currently the value is + **discarded** — only the journal is decoded. +- `session_limit` is a **hard limit on user cycles** (rv32im executor: + `CycleLimit::Hard` compares `self.cycles.user`); exceeding it is an *error* + (`bail!("Session limit exceeded")`), not a truncated session. So "halt at + gas_limit" surfaces as a tx-level failure charged at `gas_limit`; there is no + partial cycle count on the way out, and out-of-gas must be distinguished from + other executor errors by error inspection (brittle — needs a wrapper). +- **One tx ≠ one session**: chained calls run one zkVM session per call + (`validated_state_diff/mod.rs:108-127`, up to `MAX_NUMBER_CHAINED_CALLS = 10`). + Per-tx metering must thread a cumulative budget: each call's + `session_limit = gas_limit − cycles_used_so_far`, and `cycles(tx) = Σ sessions`. + This requires plumbing a gas budget through `ValidatedStateDiff:: + from_public_transaction` → `Program::execute` and returning consumed cycles up. +- Private tx "execution" is a host-side STARK receipt verification + (`circuit/mod.rs:34-41`); `PRIVATE_VERIFY_GAS` is a pricing constant, nothing + to meter. ✅ matches spec. + +### 1.5 Accounts, balances, state + +- `Account { program_owner, balance: u128, data, nonce }` + (`lee/state_machine/core/src/account.rs:92-103`) — balance is already `u128` ✅. +- `V03State` (`lee/state_machine/src/state/mod.rs:114`): `public_state: + HashMap`, `private_state: (CommitmentSet, NullifierSet)`, + `programs`. Borsh-serialized wholesale into RocksDB (head + final snapshots, + `lez/sequencer/core/src/block_store.rs:171`, `storage/`). Adding fee state + fields breaks the snapshot format (DB reset or migration). A destructuring + guard at `state/mod.rs:306` forces the decision when a field is added. +- `payer` maps to a **public** `AccountId` in both cases; a private tx today can + have zero public signers (fully shielded), so a private payer needs a new + authorized wire field (see Q3). +- Fee protocol state (`base_fee_exec/stor`, `escrow`, `window[50]`, + `payout_carry`, `height`) has no home yet. It must revert/reorg with blocks, + so it belongs in (or beside) `V03State` inside `ChainState`'s two-tier + snapshots (`lez/chain_state/src/chain.rs`). Note `height` duplicates + `block_id` (u64, genesis = 1, `lee_core::GENESIS_BLOCK_ID`, checked increments + already at `lib.rs:770`); the spec's height starting at 0 vs LEZ genesis + block_id = 1 needs a fixed mapping. + +### 1.6 Producer identity & multi-sequencer + +- Multi-sequencer turns exist (`is_our_turn`, + `block_publisher.rs:382`; turn notifications from zone-sdk). Competing blocks + at a height are resolved by the channel/finality (`lib.rs:632-661`). +- The producer's *L2 account* appears nowhere. `SequencerConfig.signing_key` + (`config.rs:58`) signs blocks; `AccountId::from(&PublicKey)` exists, so + producer-account = account of block-signing key is derivable **if the pubkey + is in the block or known from a registry**. Header carries only a signature → + needs a header field or an in-state sequencer registry (Gap G6, Q4). + +### 1.7 RPC / wallet / indexer / FFI surfaces + +- Sequencer RPC (`lez/sequencer/service/rpc/src/lib.rs`): `send_transaction`, + `get_account*`, `get_block*` … Needs: current/base-fee query (wallets must + price `max_fee` and private fees from public state, SPECS §Transactions), and + admission-time fee checks in `service.rs:60-71` (today only size + stateless + sig check). +- Wallet (`lez/wallet/`): builds and signs txs; has a **vestigial, unused + `GasConfig`** (`config.rs:22-38`) from an older fee idea — replace. Needs + payer/gas_limit/tip/max_fee UX, base-fee polling, private-fee computation. +- Indexer (`lez/indexer/core`): replays blocks through the same + `accept_block`/apply path; explorer + `indexer_ffi` + `lez-indexer-module` + would want base-fee/fee-history queries (new FFI methods → cbindgen header + regen → module flake bumps, per workspace CLAUDE.md). +- `tools/cycle_bench` exists in-repo and measured the numbers in SPECS Annex C. + +### 1.8 L1 touchpoints (verified no conflict) + +- L1 posting is funded by the zone-sdk node wallet: `BedrockConfig { funding_key, + priority_fee }` (`lez/sequencer/core/src/config.rs:73-83`) → + `FundingConfig { funding_pk, max_tx_fee, priority_fee }` + (`logos-blockchain/zone-sdk/src/sequencer/types.rs:134`). Out-of-band exactly + as the spec scopes it; the fee mechanism reads nothing from Bedrock. ✅ +- Blocks are opaque `ZoneMessage::Block` bytes to the L1; changing the L2 wire + format does not touch the zone-sdk contract. + +--- + +## 2. Gaps and contradictions (spec ⇄ code) + +**G1 — No fee fields in the wire format.** `payer`, `gas_limit`, `tip`, +`max_fee` must be added to the public tx message (signed ⇒ inside the hashed +`Message`, not the witness set). Breaks tx/block hashes and every signer, +including hardware (`keycard_wallet`) and the PPE circuit's message hash for +private txs if their message changes. Version-prefix bump (`/LEE/v0.4/`?) +recommended. + +**G2 — No metering plumbed.** Cycle counts exist (`SessionInfo::cycles()`) but +are discarded; no cumulative per-tx budget across chained calls; out-of-gas is a +string error. Requires an `ExecutionOutcome { cycles, … }` return through +`Program::execute` → `ValidatedStateDiff` → `execute_on_state`, and replacing +the fixed 32M `MAX_NUM_CYCLES_PUBLIC_EXECUTION` with the tx `gas_limit` budget. +Also note: **executor cycle counts must be identical across risc0 versions** — +a risc0 upgrade becomes a consensus-breaking protocol-version change. + +**G3 — Private tx size is not constant.** Variable vecs, variable ciphertexts, +variable signature count, ~223 KB proof. The spec's `PRIVATE_GAS_STOR` requires +envelope-level padding of the whole private tx to a constant canonical size, and +the current `PRIVATE_GAS_STOR = 224,063` assumes zero envelope overhead — the +real envelope (enum tag, vec lengths, pubkeys, windows…) must be measured and +the constant re-pinned (SPECS' own TODO). Also `RISC0_DEV_MODE` fake receipts +are tiny — dev-mode padding must still hit the same constant size or dev/prod +diverge on `data_bytes`. + +**G4 — Reverted-tx replay / nonce question.** Spec: tx-level failure discards +execution effects but keeps the fee. If the discarded effects include the nonce +increment, the *same signed tx* can be re-included every block, draining the +payer's balance via fees with no user action. Needs an explicit rule (advance +payer nonce on charged failures, EIP-1559-style) — currently nonces only advance +inside successful diffs. **Spec is silent; must be decided** (Q5). + +**G5 — Block-validity semantics flip.** Today any failing tx rejects the whole +block (`apply.rs:157`); builder pre-filters failures so it never happens in +practice. Spec requires: reserve-failure ⇒ reject block; execution +failure/revert ⇒ keep tx, charge fee, discard effects. The shared apply path +and the builder's skip-on-failure logic (`lib.rs:939`) both change: an included +failing tx becomes *normal*, and the builder must stop dropping them (or it +under-charges vs. peers who validate its block). + +**G6 — No producer account.** Header has an unverified signature, no pubkey, no +producer id; the payout credit target does not exist. Needs a `producer` +(pubkey or AccountId) header field + signature verification in the shared apply +path, or a consensus-known sequencer registry. Note the multi-sequencer work +(PR 603 line) intersects here. + +**G7 — System/sequencer transactions have no payer.** Clock tx (mandatory, +unsigned, canonical — equality-checked at `apply.rs:135`), bridge-deposit +mints, cross-zone dispatch deliveries. Spec's invariant 6 says producer pays +for its own txs; the clock tx literally cannot carry a payer today without +breaking its canonical-equality check. Decide: exempt system txs (spec change) +or make producer the payer (wire + validation change + producer must keep a +funded account; and a deposit-mint whose producer can't pay blocks bridge +liveness) (Q6). + +**G8 — Program deployment is unpriced and unsigned.** +`from_program_deployment_transaction` (`validated_state_diff/mod.rs:441`) +accepts any bytecode from anyone for free; it's also by far the largest public +tx (whole ELF on-chain ⇒ storage gas would price it heavily; execution gas ~0). +The spec doesn't mention this tx kind at all (Q2). + +**G9 — Genesis & state-format migration.** Genesis fee state per SPECS §Genesis; +`MAX_GAS_r = 2·TARGET_GAS_r` validation; fee state fields break Borsh snapshot +compat (testnet reset vs migration, Q8). Genesis block (id 1) contains +`GenesisAction` supply txs — presumably fee-exempt (height 0 → first fee block +mapping must be pinned). + +**G10 — Mempool/admission has no fee awareness.** FIFO channel; no +`max_fee`-vs-current-base-fee admission check, no tip ordering, no per-payer +balance check at admission. Minimal viable: admission-time static fee-validity ++ balance check; ordering by tip is optional per spec. + +**G11 — Storage-cap vs `max_block_size` overlap.** `MAX_GAS_STOR` = 1,000,000 +bytes of *transaction* bytes; `max_block_size` = 1 MiB of *whole block* +including header/framing (producer-borne per spec). The two coexist but the +config value must be ≥ storage cap + framing; ideally `MAX_GAS_STOR` becomes +the consensus cap and `max_block_size` a derived/operational bound. + +**G12 — `escrow`/`window`/`payout_carry` conservation vs supply.** Balances are +u128 and supply 10¹⁹ < u64::MAX ✅; but fee debits/credits must not collide +with the bridge-escrow accounting guards (`validate_bridge_account_modification`) +— fee settlement happens outside program execution, so guards are unaffected as +long as fee logic runs at the block layer, not as a program. + +--- + +## 3. Proposed workstreams (dependency order) + +**W0 — fee-core crate (pure).** New `lez/fee_core` (or `lee/fee`): constants, +`FeeState`, `next_base_fee`, reserve/settle arithmetic, window/payout, all +integer-only u64/u128, mirroring SPECS Annex B; property tests + cross-check +against the Python/Rust reference vectors. No deps on the rest of LEZ. +*Blocks nothing; everything depends on it.* + +**W1 — wire format.** Fee fields in public `Message`; private envelope with +constant-size padding; re-pin `PRIVATE_GAS_STOR`/`PRIVATE_PAD_BYTES`; version +prefixes; producer identity in the header (pending Q4); `data_bytes` +definition. *Depends: Q1–Q4, Q6.* + +**W2 — metering.** `ExecutionOutcome{cycles}` through `Program::execute`, +cumulative budget across chained calls, structured out-of-gas error, +`gas_limit` replaces the 32M constant. *Independent of W1.* + +**W3 — block transition.** Fee state into `ChainState`/`V03State` snapshots; +reserve→execute→settle in `apply_block_to_state` + builder parity in +`build_block_from_mempool`; tx-failure-keeps-fee semantics; caps; escrow/ +window/payout; producer credit; invariants as debug/consensus checks. +*Depends: W0–W2.* + +**W4 — admission & mempool.** RPC static fee-validity, balance pre-check, +(optional) tip ordering. *Depends: W1, W3.* + +**W5 — client surfaces.** Base-fee RPC on sequencer+indexer, wallet fee UX +(max_fee, gas_limit estimation via dry-run executor, private fee preview), +explorer/FFI/module plumbing. *Depends: W3.* + +**W6 — genesis/config/migration + e2e.** Genesis validation, testnet reset +story, integration tests (RISC0_DEV_MODE=1), `just build-artifacts` refresh, +cross-check harness vs SPECS Annex A. *Last.* + +--- + +## 4. Risks + +1. **Consensus determinism of cycle counts** across risc0 versions/platforms — + pin risc0 exactly; treat upgrades as protocol versions. Executor user-cycles + are documented deterministic, but this is the single riskiest assumption: + validate with a multi-platform CI check early. +2. **Wire-format break blast radius**: wallet, keycard hardware signing, PPE + circuit output hashing, cross-zone peers, FFI modules, explorer — one + coordinated cutover. +3. **Fee-state snapshot compat**: every stored V03State (sequencer + indexer + + wallet-side caches) resets or migrates. +4. **Out-of-gas detection via error strings** — needs a structured error from + the risc0 wrapper, else spam txs could be mis-classified as consensus faults. +5. **Producer liveness vs producer-pays** for system txs (clock/deposits): + an underfunded producer halts its own block production. +6. **Private-fee UX**: payer account funding "without linking identities" is + out of scope for the spec but *not* for a usable testnet — the wallet team + needs at least a stopgap (public payer account) and that stopgap leaks + linkage; flag to product. diff --git a/.claude/lez-fees/EXIT-CODES.md b/.claude/lez-fees/EXIT-CODES.md new file mode 100644 index 000000000..1348ed1a0 --- /dev/null +++ b/.claude/lez-fees/EXIT-CODES.md @@ -0,0 +1,378 @@ +# Guest exit codes instead of panics — feasibility + +**Verdict: amenable, with caveats.** risc0 gives us exactly the primitive the proposal assumes, the +host-side seam is two functions wide, and the fee settlement arm needs *no* change at all. The cost +is guest churn (~222 sites across 16 programs) and a mandatory artifact rebuild that changes every +program image ID. + +Scope of this doc: the *public* execution path (`Program::execute` → `validated_state_diff` → +`apply_charged`). That is the only path that meters cycles for fees. + +--- + +## 1. How guests fail today + +Every LEZ guest is `fn main()` with no return value. The only exit from a failed execution is a +Rust panic, which the risc0 guest runtime turns into `sys_panic` and the host turns into an +`anyhow::Error` — with **no `SessionInfo`**, therefore no cycles. + +### Catalog (program guests, `#[cfg(test)]` bodies excluded) + +`lez/programs/*/src/**` — 34 files, 3 449 loc, **222 failure sites**: + +| Form | Count | Convertible to an exit code? | +|---|---|---| +| `.expect(...)` | 92 | Yes — almost all are user-facing revert conditions, not invariants | +| `assert!` | 55 | Yes | +| `assert_eq!` | 53 | Yes | +| `panic!` | 17 | Yes | +| `.unwrap()` | 2 | Yes | +| `unreachable!` | 2 | Judgment — genuine "can't happen" arms | +| `assert_ne!` | 1 | Yes | + +Per program (loc / panic / unwrap+expect / assert): + +``` +amm 1270 8 29 41 cross_zone_inbox 527 3 9 21 +token 854 5 31 22 bridge 253 2 2 9 +associated_ta 298 0 6 3 wrapped_token 279 3 6 12 +bridge_lock 180 2 4 7 cross_zone_outbox 165 1 3 5 +vault 147 0 3 2 faucet 139 0 2 3 +clock 136 3 4 0 pinata_token 118 0 2 2 +authenticated_t 115 0 4 2 pinata 94 0 4 2 +ping_sender 59 0 1 1 ping_receiver 48 0 2 2 +``` +(counts include each program's `core/` crate; see the split below) + +The `.expect` messages confirm these are reverts, not invariants — a sample of the 92: +`"Sender has insufficient balance"`, `"Insufficient balance to burn"`, `"Total supply overflow"`, +`"Transfer requires exactly 2 accounts"`, `"payload decodes to a wrapped-token instruction"`, +`"Token A should have a nonzero amount"`, `"reserve * amount_out overflows u128"`. + +### Shared `*_core` crates are a different population + +`lez/programs/*/core/src/**` — 12 files, 1 227 non-test loc, **28 sites**: 18 `.expect`, +7 `unreachable!`, 1 `assert!`, 1 `assert_eq!`, 1 `panic!`. These compile for the **host too** +(the wallet links `token_core` etc.), so they cannot call `env::exit`. Their messages are genuine +invariants (`"Serialization to Vec should not fail"`, `"Token definition encoded data should fit +into Data"`). **Leave them alone.** + +### SDK layer + +`lee/state_machine/core/src/program/mod.rs`: + +- **`read_lee_inputs`** (`:645-662`) — line **652** is + `T::deserialize(&mut Deserializer::new(instruction_words.as_ref())).unwrap()`. This is the + **only attacker-controlled deserialization in the guest** (`instruction_data` comes straight off + the transaction). Today a malformed instruction payload is an uncatchable guest panic, i.e. a + free-to-produce full-budget charge. High-value single-line fix. +- The three preceding `env::read()` calls read host-constructed values (program id, caller id, + pre-states); those are well-formed by construction. +- **`ProgramOutput::write`** (`:470-472`) is just `env::commit(&self)`. There is no entry/exit + macro, no `Result`-returning main convention, nothing to hook. Each program's `main` is + hand-rolled (see `lez/programs/token/src/main.rs`). + +### Early `return` without writing output — already a distinct, cheaper failure + +Several test guests do `let Ok([a, b]) = <[_;2]>::try_from(pre_states) else { return; };` +(`lee/state_machine/test_methods/guest/src/bin/missing_output.rs`, `extra_output.rs`, …). What the +host sees: the session **completes** with `Halted(0)` and an **empty journal**, so +`default_executor().execute()` returns `Ok(SessionInfo)` *with cycles*, and then +`Program::execute` (`lee/state_machine/src/program/mod.rs:95-98`) throws them away because +`journal.decode()` fails: + +```rust +let cycles = session_info.cycles(); // :92 — measured! +let program_output = session_info.journal.decode() + .map_err(|e| LeeError::ProgramExecutionFailed(e.to_string()))?; // :95-98 — discards `cycles` +``` + +**This is a bug independent of the exit-code proposal**: a class of failures already has exact +cycles available and is billed at zero execution gas. Worth fixing on its own. + +### Test guests + +`lee/state_machine/test_methods/guest/src/bin/` — 29 guests, 32 sites (14 `.expect`, 6 `panic!`, +6 `assert!`, 5 `.unwrap()`, 1 `assert_eq!`). Only those whose tests assert on the *kind* of failure +need touching. + +--- + +## 2. risc0 3.0.5 mechanics — the facts, with evidence + +Registry root: `/Users/erhant/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/` + +**(a) `env::exit(code)` exists and always finalizes the journal.** +`risc0-zkvm-3.0.5/src/guest/env/mod.rs:180-183` +```rust +pub fn exit(exit_code: u8) -> ! { finalize(true, exit_code); unreachable!(); } +``` +`finalize` (`:157-175`) hashes whatever was committed into a journal digest and passes it to +`sys_halt(user_exit, &output_words)` — **the journal is committed regardless of the exit code**. +Codes are `u8` (0–255). + +**(b) A nonzero halt is `Ok`, not `Err`, and carries full cycle accounting.** +`risc0-zkvm-3.0.5/src/host/server/exec/executor.rs:256` computes +`exit_code_from_terminate_state(...)` which maps `halt::TERMINATE` to `ExitCode::Halted(user_exit)` +for *any* user exit (`risc0-zkvm-3.0.5/src/claim/receipt.rs:310-324`) — no `bail!`. +`risc0-zkvm-3.0.5/src/host/client/prove/local.rs:60-79` then returns +`Ok(SessionInfo { segments, journal, exit_code, receipt_claim })`. `SessionInfo::cycles()` +(`src/host/api/mod.rs:403-409`) sums per-segment user cycles. **So a guest that exits nonzero gives +us exact cycles.** This is the load-bearing fact. + +**(c) The journal survives a nonzero exit.** +`executor.rs:258-268`: +```rust +let session_journal = result.claim.output.and_then(|digest| + (digest != Digest::ZERO).then(|| std::mem::take(&mut *journal.buf.lock().unwrap()))); +if !exit_code.expects_output() && session_journal.is_some() { /* debug log only */ } +``` +and `risc0-binfmt-3.0.4/src/exit_code.rs:88-93`: +```rust +pub fn expects_output(&self) -> bool { + match self { ExitCode::Halted(_) | ExitCode::Paused(_) => true, + ExitCode::SystemSplit | ExitCode::SessionLimit => false } +} +``` +`Halted(n)` expects output for **any** `n`, so the journal is retained. That means the host must +*decide* to ignore it when `n != 0`, and it also means a guest could optionally commit a structured +revert payload before exiting. See §3 for the rule. + +**(d) A guest panic is an `Err` with no `SessionInfo` — the problem, confirmed.** +The Rust panic handler is `risc0-zkvm-platform-2.2.2/src/rust_rt.rs:30-34` → +`sys_panic`, and the host handler is +`risc0-zkvm-3.0.5/src/host/server/exec/syscall/panic.rs:41`: +```rust +bail!("Guest panicked: {msg}"); +``` +This propagates out of `exec.run` → `run_with_callback` → `execute` as `Err`. Nothing is +recoverable from it. + +**(e) Panics cannot be caught in-guest.** `risc0-build-3.0.5/src/lib.rs:488` compiles guests with +`-C panic=abort`, and `:408` builds std with `panic_abort`. `catch_unwind` is therefore not an +escape hatch. Confirms hard-case (a) below. + +**(f) `session_limit` is orthogonal and unchanged.** +`risc0-circuit-rv32im-4.0.4/src/execute/executor.rs:243-249` bails with +`"Session limit exceeded: {n} >= {max}"` from inside the run loop — also no `SessionInfo`. LEZ +already special-cases that string (`lee/state_machine/src/program/mod.rs:15, :132-148`) and meters +it at the whole budget. **An exit-code refactor does not touch this path**; a guest that exits with +a code before hitting its limit produces exact cycles below the budget, and a guest that hits the +limit still bails. Note `ExitCode::SessionLimit` is documented as never produced +(`risc0-binfmt-3.0.4/src/exit_code.rs:55-59`). + +**(g) Proving requires `Halted(0)` — matters only for the private path.** +`risc0-zkvm-3.0.5/src/receipt.rs:160-201`: `Receipt::verify` builds `ReceiptClaim::ok(...)` and +compares digests, so a receipt with `Halted(1)` fails `verify`. In the wallet's +`execute_and_prove_with_padded_inputs` +(`lee/state_machine/src/privacy_preserving_transaction/circuit/mod.rs:106-141`) each program +receipt is fed to `env_builder.add_assumption(...)`. A nonzero-exit receipt could never compose. +This is fine: in the private path a failing program means the wallet cannot build a transaction at +all — nothing reaches consensus, nothing is billed. + +**(h) Arithmetic overflow is *not* a panic source in guests today.** Neither the root +`Cargo.toml` nor `lez/programs/Cargo.toml` sets `[profile.release] overflow-checks`, and +`cargo risczero build` builds release. Overflow **wraps silently**. (Which is why the programs are +full of `checked_sub(...).expect(...)` — and it means overflow drops out of the residual-panic +list, though it stays a correctness footgun worth its own ticket.) + +--- + +## 3. Host-side shape + +### `Program::execute` (`lee/state_machine/src/program/mod.rs:69-101`) + +Only two call sites exist: the chain loop (`validated_state_diff/mod.rs:211`) and +`program/tests.rs`. Change the return type: + +```rust +pub(crate) enum ProgramRun { + Completed { output: Box, cycles: u64 }, + Reverted { code: u8, cycles: u64 }, +} + +// inside execute(), after `let session_info = execute_session(env, self.elf(), cycle_budget)?;` +let cycles = session_info.cycles(); +match session_info.exit_code { + ExitCode::Halted(0) => { + let output = session_info.journal.decode() + .map_err(|e| LeeError::MalformedProgramOutput { cycles, reason: e.to_string() })?; + Ok(ProgramRun::Completed { output: Box::new(output), cycles }) + } + // Journal deliberately ignored: `expects_output()` keeps it alive on a nonzero halt, but a + // reverting program has no state diff to contribute. + ExitCode::Halted(code) => Ok(ProgramRun::Reverted { code: code as u8, cycles }), + other => Err(LeeError::ProgramExecutionFailed(format!("unexpected exit: {other:?}"))), +} +``` + +**"No output because reverted(code)" vs "malformed guest" is unambiguous**: the exit code decides, +before the journal is even looked at. `Halted(0)` + undecodable journal = a buggy program (today's +silent early-`return`); `Halted(n≠0)` = a revert. Both now carry cycles — the malformed case needs +`cycles` threaded onto the error (a new `LeeError` variant or an out-param mirroring the existing +`cycles_used: &mut u64` style). + +### Chain loop (`validated_state_diff/mod.rs:186-410`) + +The `match program.execute(...)` at `:211-245` gains one arm: + +```rust +Ok(ProgramRun::Reverted { code, cycles }) => { + *cycles_used = cycles_used.saturating_add(cycles); + return Err(LeeError::ProgramReverted { program_id: chained_call.program_id, code }); +} +``` + +and the `TBA(revert-metering)` comment block at `:235-244` deletes. `ExecutionOutcome`'s doc +(`:49-65`) loses its "the executor discards the failing session's count" paragraph. + +### Fee settlement — **no change required** + +`lez/chain_state/src/apply.rs:599-626` already does: + +```rust +let (outcome, result) = ValidatedStateDiff::from_public_transaction_metered(...); +let charged_cycles = outcome.cycles.min(gas_limit); // :605 +... +Err(err) => { state.advance_replay_nonces(&signers); + summary.tx_outcomes.push(TxApplyOutcome::Reverted { reason: ... }); } // :618-625 +``` + +Exact-cycle billing slots straight into `charged_cycles` — the revert arm already exists, the clamp +already exists, `fee_actual_base(charged_cycles, ...)` already prices it. This matches +`SPECS.md:132` ("A transaction that fails, reverts, or halts at its limit is charged for the cycles +consumed to that point"), which the interim full-budget charge currently violates. + +Optional nicety: widen `TxApplyOutcome::Reverted` (`apply.rs:76-84`) with `code: Option` so the +explorer can show *why*. + +### Tests that pin the current behaviour and will flip + +- `lee/state_machine/src/validated_state_diff/tests.rs:664-685` + `a_guest_panic_meters_only_the_sessions_that_completed` — asserts `outcome.cycles == 0`. + Written to fail loudly when this lands (`:663`). +- `lez/chain_state/src/apply.rs:1226-1257` — the mirrored block-level pin. + +--- + +## 4. Hard cases + +**(a) Panics the program does not control.** With `panic=abort` (§2e) there is no in-guest catch. +The residue after the refactor: + +| Class | Present in LEZ guests? | +|---|---| +| Arithmetic overflow | **No** — overflow-checks off, wraps (§2h) | +| Dynamic OOB indexing | Rare — programs destructure fixed-size arrays via `try_into().expect(...)`, which becomes a code | +| Division by zero | ~9 `/` or `%` occurrences total, mostly by constants | +| Allocator exhaustion | `risc0-zkvm-platform-2.2.2/src/heap/bump.rs:94` → `sys_panic`. Only on pathological allocations | +| Stack overflow | Possible, unbounded recursion only | +| Panics in third-party guest deps | Possible | + +So **essentially 100% of the *deliberate* failure signals convert** (222/222 program-controlled +sites), and the panic-only residue is genuine bugs plus resource exhaustion. But — + +> **A deployed program is user-supplied bytecode and can always choose to panic.** The full-budget +> charge must remain as the fallback arm forever. It is not a stopgap; it is the correct price for +> an uncooperative program, and it is what keeps this from becoming a spam discount. The exit-code +> mechanism is a *cooperation* incentive for well-behaved programs, not an enforcement mechanism. + +This has a flip side worth a decision: exact-cycle billing makes cheap reverts *cheap*, which +lowers the cost of spamming failing transactions relative to today's full-budget charge. SPECS +already rules for exact cycles, so this is a ratification, not a new question — but it should be +stated in the PR. + +**(b) Mid-chain revert.** Expressible and clean. The chain shares one cumulative budget +(`:194`, `remaining_cycles = cycle_budget - cycles_used`) and `cycles_used` accumulates per link +(`:246`). A revert at link 3 stops the chain — same as today (the whole transaction reverts and +`state_diff` is discarded; there is no partial-chain commit) — but now the outcome carries +links 1+2 (already exact) **plus** link 3 (newly exact). Strictly better, no new semantics. +`MAX_NUMBER_CHAINED_CALLS = 10` (`lee/state_machine/core/src/program/mod.rs:14`) is unaffected; +exceeding it is a host-side `ensure!`, not a guest failure. + +**(c) The privacy-preserving circuit — out of scope.** `lee/privacy_preserving_circuit/src/**` +(967 loc, 42 failure sites: 18 `assert_eq!`, 10 `assert!`, 9 `.expect`, 4 `panic!`, +1 `unreachable!`) is proved **client-side** by the wallet (`circuit/mod.rs:152-156`) and only +*verified* on-chain (`validated_state_diff/mod.rs:646-669`). Its failures are "the wallet cannot +build a transaction" and "the proof is invalid" — and `SPECS.md:100` rules that an invalid private +proof is an *invalid* transaction, not a reverted one: it cannot be included, so nothing is billed. +`ExecutionOutcome::FREE` is used for every non-public transaction +(`validated_state_diff/mod.rs:46-47, 67-70`). **Do not touch it in this PR.** §2g additionally +shows exit codes would actively break receipt composition there. + +**(d) "Guest wrote no output" as a protocol state.** Today it collapses into +`LeeError::ProgramExecutionFailed` via a journal-decode error, and the shape is already policed +downstream (`InvalidProgramBehaviorError::DeclaredAccountMissingFromOutput`, +`MismatchedPreStatePostStateLength`, `DefaultAccountModifiedWithoutClaim`). After the refactor it +splits cleanly in two — `Halted(0)` + no journal = malformed program; `Halted(n≠0)` = revert — and +**both become billable at exact cycles**, closing the current zero-charge hole. No protocol rule +depends on the two being conflated. + +--- + +## 5. PR scale + +| Layer | Files | Sites | Character | +|---|---|---|---| +| Guest SDK (`lee_core::program`) | 1 | ~5 | New: `revert(code) -> !`, code constants, `require!` macro, `OrRevert` ext trait; fix `:652` `.unwrap()` | +| `lee/state_machine/src/program/mod.rs` | 1 | ~40 loc | New `ProgramRun` enum, exit-code match, journal-decode fix | +| `lee/state_machine/src/error.rs` | 1 | +2 variants | `ProgramReverted { program_id, code }`, `MalformedProgramOutput { cycles }` | +| `validated_state_diff/mod.rs` | 1 | ~25 loc | One new match arm, delete the `TBA` block, update `ExecutionOutcome` docs | +| **Program guests** | **34** | **222** | Mechanical *edit*, per-program *judgment* on the code table | +| Test guests (`test_methods`) | ≤29 | ≤32 | Only where tests assert failure kind | +| Fee settlement (`apply.rs`) | 1 | ~0 | Optional `code` on `Reverted`; the clamp already works | +| Pinned tests | 2 | 2 | `tests.rs:664`, `apply.rs:1226` flip from "cycles lost" to "cycles exact" | + +**Effort:** 1–2 days if you adopt a flat scheme (`0` = ok, `1` = generic revert, a handful of +SDK-reserved codes, rest program-defined and unused at first). 3–5 days if each program gets a +real error enum with stable numbering — which is the version worth having, since the code is the +only thing a user or explorer will see. + +**Suggested split.** Two PRs, because the second one is the expensive one: + +- **PR-A (host only, no guest churn, no artifact rebuild).** Fix the journal-decode path so + `Halted(0)` + undecodable output keeps its cycles; land the `ProgramRun` shape and the + `Reverted` arm even though no guest emits codes yet. Zero image-ID churn, immediate win on the + early-`return` class. +- **PR-B (guest churn).** SDK helper + 222 sites + artifact rebuild + fixture regeneration. + +### Wire / state break — **yes, unavoidably, in PR-B** + +- The **journal shape does not change** on the success path: `ProgramOutput` is untouched, and a + revert commits nothing. So no serialization break. +- But **every guest ELF changes**, and `ProgramId` *is* the risc0 image ID + (`lee/state_machine/src/program/mod.rs:24-32`). So **all 17 committed artifacts change and every + program ID changes**: + - `just build-artifacts` is mandatory (rebuilds `artifacts/lez/programs/*.bin`; the privacy + circuit is untouched but the Justfile rebuilds it too). + - `lez/testnet_initial_state/src/lib.rs:224-262` deploys programs by `programs::x().id()` and + `:162, :198` set `program_owner` from them → **genesis state changes**. + - `test_fixtures/fixtures/prebuilt_sequencer_db.dump` must be regenerated. + - Any live testnet is a fresh-genesis restart, and any address derived via + `AccountId::for_public_pda(program_id, seed)` moves. + +That is a hard fork of the program set, not a soft change. It is the same cost as any guest edit, +so the right move is to batch it with whatever other guest-affecting work is queued. + +--- + +## 6. Recommendation + +Do it, in the two-PR split above. + +The mechanism is sound: risc0 gives `Ok(SessionInfo)` with exact cycles on any `Halted(n)` +(§2b), the journal survives so the host can decide policy rather than guess (§2c), and the fee +settlement path needs *no* structural change because `charged_cycles = outcome.cycles.min(gas_limit)` +already prices whatever the executor reports (`apply.rs:605`). It moves the implementation onto the +side of `SPECS.md:132` it is currently on the wrong side of. + +Two things to say out loud in the PR description: + +1. **The full-budget charge does not go away.** A deployed program can always panic on purpose; + that arm stays, and it is the correct price for an uncooperative program. +2. **This makes reverts cheaper.** That is what SPECS asks for, but it is a real change in spam + economics and should be ratified deliberately, not slipped in as an implementation detail. + +The one thing I would pull forward regardless of whether the exit-code work is scheduled: the +`Halted(0)` + undecodable-journal path already has exact cycles and throws them away +(`lee/state_machine/src/program/mod.rs:92-98`). That is a standalone bug. diff --git a/.claude/lez-fees/INCREMENTIAL.md b/.claude/lez-fees/INCREMENTIAL.md new file mode 100644 index 000000000..a8458fa9d --- /dev/null +++ b/.claude/lez-fees/INCREMENTIAL.md @@ -0,0 +1,457 @@ +Privacy-preserving executions are executed and proven locally in Risc0. The proof is bundled in a privacy transaction (with the relevant data). The sequencer verifies the proof against the chain's state and the provided data. Specifically, the sequencer uses the current state of each public account used in the privacy transaction. Validation fails when any of the public accounts' states differ from the ones used during proof generation. Thus, inducing a **race condition** in LEZ for privacy transactions. + +Consider the example: + +- Bob initializes a deshielded transfer to send Alice 5 tokens from his private account to her public account with state (`pub_alice_0`) known to him. +- During either Bob's proving time or while Bob's privacy transaction is sitting in mempool, Alice submits a public transaction that updates her account state (`pub_alice_1`). +- Once the sequencer attempts to validate Bob's transaction, Alice's account state (in LEZ) is `pub_alice_1` and not `pub_alice_0`. The transaction's proof verification fails. Thus, the sequencer rejects Bob's transaction. + +The race condition is a consequence of LEE transaction design. LEE transactions (public and privacy) "fully" replace account states rather than simply update the entries. This approach is acceptable for public-only and private-only transactions. However, this design is detrimental for hybrid transactions (as described above). + +In this document, we propose account state diffs to facilitate incremental updates to account states. + +## 1 Big idea: incremental account update + +We propose change to LEZ program design. Instead of LEZ programs emitting new account states, LEZ programs emit the changes that should be applied to the account. This enables LEZ programs to output updates that are "independent" of the input account's `pre_state`. This is crucial for hybrid accounts. + +LEZ programs consist of two modes: + +- `Execute` is used for the usual program logic that is called by a transaction's execution. +- `UpdateFromDiff` consist of the program-specific logic to apply updates to a given account. + +**How are updates to an account handled?** + +- Desired balance changes are recorded; e.g., amount and operation (add or subtracted) from the account's balance. +- Desired changes to `data` field. The logic for updating `data` is program-defined. + +**How is `UpdateFromDiff` handled by transaction types?** + +- Public transactions. Sequencer executes both `Execute` (program call) and `UpdateFromDiff`. The sequencer applies updates for each account using `UpdateFromDiff` (for each program call). +- (Fully) Private transactions. `Execute` and `UpdateFromDiff` is handled entirely in Risc0. +- Privacy transactions. `Execute` and `UpdateFromDiff` is handled in Risc0. Additionally, for each public account (based on `InputAccountIdentity` in the `privacy_preserving_circuit`) the updates are accumulated (per account) and included in the transaction's reciept. The sequencer applies these updates to the public account's current state. + +## 2 Core Types + +### 2.1 `AccountDiff` and `AccountDiffOutput` + +Programs produce updates to accounts as `AccountDiff`. This interface is defined: + +`AccountDiff` is the common interface that LEZ emit. + +```rust +pub struct AccountDiff { + pub id: AccountId, + pub diff_balance: BalanceDiff, + pub diff_data: Option, // None signifies no change. +} +``` + +- `id` — `AccountId` of the account this `AccountDiff` corresponds to. +- `diff_balance` — the net change to `Account.balance`. +- `diff_data` — program specified encoding that describes how `Account.data` is updated. + +`AccountDiffOutput` is the program output wrapper: + +```rust +pub struct AccountDiffOutput { + diff: AccountDiff, + claim: Option, +} +``` + +This wrapper is the replacement for `AccountPostState`; handles the claiming mechanism. As such the functions for `AccountDiffOutput` are adapted from `AccountPostState`: + +- `new(diff)` — no claim. +- `new_claimed(diff, claim)` — unconditional claim request. +- `new_claimed_if_default(diff, pre_state_program_owner, claim)` — claims only if the account +is unowned. +- `diff()`/`diff_mut()` and `required_claim()` read the two fields back out. + +### 2.2 `BalanceDiff` + +Native token `Balance` has Protocol-level semantics that applies to all accounts (regardless of `program_owner`). Programs can only indicate the amount that an account is either increased or decrease. This is indicated with `BalanceDiff`: + +```rust +enum BalanceDiff { + Add(Balance), + Sub(Balance), +} +``` + +At Protocol-level (either by the sequencer or in `privacy_preserving_circuit`) the `Account.balance` is updated with: + +```rust +fn apply_balance_diff(current: Balance, diff: BalanceDiff) -> Result { + match diff { + BalanceDiff::Add(amount) => current.checked_add(amount).ok_or(BalanceDiffError::Overflow), + BalanceDiff::Sub(amount) => current.checked_sub(amount).ok_or(BalanceDiffError::InsufficientBalance), + } +} +``` + +## 3 Program design + +Incremental account updates introduces a two-path execution flow to LEZ programs: + +- `Execute` handles program logic to construct `AccountDiff`s. +- `UpdateFromDiff` specifies how program owned accounts are updated given a `pre_state: Account` and `diff_data: Data`. This function outputs `Data`. +This design ensures that the same program ELF can be used for both program calls and updates. See +Appendix A.1 (`simple_balance_transfer`) and Appendix A.2 (`data_changer`) for examples of this design. + +Program's `UpdateFromDiff` does not apply `account.balance` updates. `Balance` updates are handled at the protocol-level. This restricts a program's ability for direct manipulation to only `account.data`; changes to `program_owner` and `balance` are requested through the program's `Execute` logic but are handled at the protocol-level. + +Each program defines how `data` is handled for their accounts. + +- `authenticated-transfer` program: `data` is always taken to be default. +- `Token` program: `data` either defined by Token holding or Token definition. +Due to this, incremental account update imposes a new requirement on developers. Each program must include a function `update_from_diff(pre_state: Account, diff_data: Data) -> Result`. + +Programs no longer output the accounts' `pre_states`. This was used for consistency checks by the privacy preserving circuit. However, with `AccountDiff`, the difference is applied to any `pre_state` of the account. Due to this, the program's logic `UpdateFromDiff` defines and enforces rules for updates. At the program level, `diff_data: Data` can serialize a different struct than used by the `account.data: Data`. E.g., AMM style account may include values or computations from the prover's `pre_state` for threshold enforcement. + +### 3.1 Unneeded tests with `AccountDiff` + +Tests in LEZ that are no longer needed in `test-methods`: + +- **`malicious_authorization_changer`** — tried to forge `is_authorized: true` for an account it +doesn't control. Not exploitable: a program can no longer output a `pre_state` at all, only an +`AccountDiffOutput` (`diff_balance`, `diff_data`, `claim`), so there's no channel for a program +to change an account's authorization. +- **`nonce_changer`** — directly incremented `account_post.nonce`. `AccountDiff` has no `nonce` +field, so a program has no channel to change it. +- **`program_owner_changer`** — directly set `account_post.program_owner`. `AccountDiff` has no +`program_owner` field either; ownership only ever moves through the claim mechanism now. +- **`modified_transfer_program`** — skipped its own balance check, relying on protocol-level +conservation to catch the overflow. `apply_balance_diff` now applies checked arithmetic to +every diff unconditionally, so this no longer depends on the program at all. + +## 4 Protocol-level changes + +### 4.1 Current `validate_execution` (public transaction) + +LEE supports arbitrary program design. LEE only permits accounts to be updated by the program that "owns" it (e.g., `Account.program_owner = program_id`). This prevents malicious programs from manipulating another program's accounts. + +LEE provides several rules (`validate_execution`) that guarantee a given execution's updates to accounts are valid: + +1. The output's `pre_states` contain unique account IDs. Each `AccountId` in the list is unique. +2. The output's `pre_states` and `post_states` have the same length N. +3. Program cannot update an account's nonce. For all i in 0..N, pre_states[i].account.nonce == post_states[i].account.nonce. +4. Program cannot change the program owner of an account. For all i in 0..N, pre_states[i].account.program_owner == post_states[i].account.program_owner. +5. Program can only decrease the native token balance for accounts that the program owns. For all i in 0..N, if post_states[i].account.balance < pre_states[i].account.balance, then pre_states[i].account.program_owner == executing_program_id. +6. Program can only change an account's data for accounts that the program owns (or if the account is default). For all i in 0..N, if pre_states[i].account.data != post_states[i].account.data then either pre_states[i].account == Account::default() or pre_states[i].account.program_owner == executing_program_id. +7. Any account that has default program owner after execution must have been a default account before execution. For all i in 0..N, if post_states[i].account.program_owner == DEFAULT_PROGRAM_ID then pre_states[i].account == Account::default(). +8. The sum of balances across all pre_states equals the sum across all post_states. + +Programs output updates as `post_state: AccountPostState`. The account's `post_state` is a full replacement for the current account's state (based on the provided `pre_state: AccountWithMetadata`). Additionally, `post_state` specifies whether a program execution `claims` the account (indicates how `post_state.account.program_owner` should be set to `program_id` at the Protocol-level). + +### 4.2 Proposal changes to `validate_execution` (public transaction) + +Incremental account update changes the design paradigm for programs. Programs only specify the updates that are made to an account by outputting `AccountDiffOutput`. This indicates changes to `Account.balance` and `Account.data`, and whether the program `claims` the account. + +Programs no longer have the potential to update `program_owner` or `nonce` directly. Thus, we can remove (3), (4), and (7) from the `validate_execution` workflow. + +- (3) and (4) are removed because `AccountDiff` has no `nonce`/`program_owner` field at all. Thus, programs cannot alter these account fields. +- (7) is removed. `AccountDiff` does not permit `program_owner` to revert back to `DEFAULT_PROGRAM_ID`. Once a materialized `account.program_owner` is always inherited or overwritten (PDA grifting protection). +The `validate_execution` constraints can be reformulated in terms of `AccountDiff` as: +1. The output's `pre_states` contain unique account IDs. Each `AccountId` in the list is unique. +2. The output's `pre_states` and `post_diff` have the same length N. +3. Program can only decrease the native token balance for accounts that the program owns. For all i in 0..N, if `post_diff.account_diff.diff_balance = Sub(value)` for some `value > 0`, then `pre_states[i].account.program_owner == executing_program_id`. +4. Program can only change an account's data for accounts that the program owns (or if the account is default). For all i in 0..N, if `post_diff.diff_data.is_some()` then either `pre_states[i].account == Account::default()` or `pre_states[i].account.program_owner == executing_program_id`. +5. Across every diff produced by this one call, `sum(Add amounts) == sum(Sub amounts)`. + +These approaches differ for 3, 4 and 5 as follows: + +| Rule | Old (current) | New (diff-native) | +| --- | --- | --- | +| Unauthorized balance decrease | `post.balance < pre.account.balance` | `matches!(diff.diff_balance, BalanceDiff::Sub(amount) if amount > 0)` | +| Unauthorized data modification | `pre.account.data != post.data` | `diff.diff_data.is_some()` | +| Total balance conserved | `sum(pre.balance) == sum(post.balance)` | `sum(Add amounts) == sum(Sub amounts)` across the call's diffs | + +## 5 Privacy transaction changes + +### 5.1 Account generation in circuit + +- Private accounts are fully materialized within the privacy preserving circuit. E.g., updates are performed within the circuit for each iteration. The final private account states is committed to (and initial state nullified). From the observer's perspective, this version of the privacy preserving circuit behaves the same as the current version. `AccountDiff`s are not committed to. +- Public accounts are materialized within the privacy preserving circuit so that `post_state`s can be used as the `pre_state` for consecutive chain calls. The `AccountDiff`s for each update is saved and included in the privacy preserving circuit's journal; the sequencer uses this to replay the executions. + +**Optimization**: Compress `AccountDiff`s for a given account into a single `AccountDiff`. This guarantees that each public account requires only a single update from the sequencer. + +- This saves on transaction size due to `Data` field used in `AccountDiff`. +- Program design pattern for privacy execution is leaked by observing the number of updates to a specific `program_owner`'s account. + +### 5.2 Privacy transaction processing by sequencer + +1. Proof verification. If proof fails, then the sequencer aborts. +2. Sequencer replays public accounts with `message.public_diffs` in order for each entry. This derives the public account states based on the account's current state on-chain. If any any update fails (program emits an error or balance update error), then the sequencer reverts the account states and aborts. +3. Provided no errors, the sequencer appends new nullifiers and commitments to the private state, and updates the public accounts. + +## 6 Fees + +**Disclaimer**: This section makes minor assumptions concerning fees/collateral based on conversation with Sergio and Marvin. + +We assume that a collateral account (independent of the message's intended privacy transaction). This ensures that fees can be collected from a failed privacy transaction. + +### 6.1 Public transactions + +Public transactions fees for incremental account updates are handled as expected. A transaction is executed and accounts are updated until the fees are exhausted (or the computation is finished). If insufficient fees are provided, then the transaction's updates are reverted. + +### 6.2 Privacy transactions + +Each privacy transaction emits a proof. This proof provides assurances that the provided `AccountDiff`s were generated correctly (based on some `pre_state`). Due to this a privacy transaction with a valid proof may fail. There are four possibilities for privacy transaction updating LEZ state in terms of proof validity and fees: + +1. Provided proof is invalid. +2. Valid proof, but insufficient fees provided to update accounts. +3. Valid proof, but `update_from_diff` produces an error. +4. Valid proof, and sufficient fees provided to update accounts. + +Every part of a message associated to an invalid proof cannot be trusted. E.g., sequencer cannot collect fees from such a transaction. Transactions with invalid proofs are simply discarded from mempool. The sequencer can collect fees from transactions from 2-4. + +Given a valid proof, the sequencer has some assurance that the fees were generated using some `pre_state`. The `pre_state` could correspond with either public or private accounts + +- Private accounts (with a valid proof) guarantee the integrity of the fees. The private account state corresponds with a valid account state commitment. As long as the provided nullifier is new, then fees can be collected. +- Fees from a public account must be checked to ensure that the fees amount can be deducted from this account. Given that the account's balance exceeds the fees amoutn, the sequencer can begin to proceed. +Once the integrity of the fees has been verified, then the sequencer can begin to apply `update_from_diff` logic to each account. + +2 and 3 fails during the `update_from_diff` process. Either the fees are exhausted before accounts are updated or an update returns Error. In either case, account states are reverted to their pre-transaction state. Except for the fees should be collected/ + +**Open question/remarks** + +- Private accounts that pay fees cannot be partially updated by the sequencer. E.g., either the private account is fully updated by the transaction (fees paid and message execution) or fully reverted. This resulted in the necessity of separate collateral account to pay the fees. Imo: collateral seems unnecessary. We can simply require private accounts used for fees are independent of the desired program's execution. + +## 7 Collisions within mempool + +Multiple transactions may appear in mempool at a time. Each node needs to be able to prioritize transactions that update the same account. + +- **Two transactions in mempool that update the same private account.** + - Detection: Both transactions include the same nullifier. + - Criteria: Provided both transactions include a valid proof, the node must discard one of these transactions. The transaction with the higher fees is maintained. + - Explanation: A valid privacy transaction proof can only be generated by an entity that possesses the account's `nsk`. Higher fees are paid either from the same entity or a member of the shared group owner of the private account. + The transaction maintained in mempool may still fail due to insufficient fees (2) or error from updating a public account (3). In this case, the private account is not updated at all. Purposeful exploit of this behavior is discouraged through fees. +- **Two transactions in mempool that increment the same public account's nonce.** + - Detection: `tx1.account_ids[i] == tx2.account_ids[j]` and `tx1.nonces[i] == tx2.nonces[j]` where `account_ids[i]` is a signer for `tx1` and `account_ids[j]` is a signer for `tx2`. + - Criteria: The transaction with higher fees is maintained. + - Explanation: Signature authorization can only be done by an authorized party, so the higher fees should be viewed as the deliberate, intended transaction. Purposeful exploit of this behavior is discouraged through fees. + +We add a requirement to prevent valid transactions from being removed through grifting. Anti-grift requirement to enter mempool: + +- The selected transaction must have payable fees. E.g., for fees from public accounts the node needs to check the accounts' state to verify fees are payable from these accounts. For private accounts, the proof must be valid, and the account paying fees must be independent of the normal execution accounts. +- Any transaction that fails the anti-grift requirement is discarded immediately. + +This rule does not guarantee that 2-3 from Section 5 cannot occur. It guarantees that fees are payable. + +**Remarks** + +- Shared group accounts could face front-running with this rule. The ramifications of this are program specific. +- Multiple transactions that are submitted to mempool with a future public account nonce re-opens a grifting issue. A "future transaction" can either be (1) processed (at the appropriate time) or (2) replaced by another transaction (by the rules above). When the "future transaction" was appended to mempool, the payable fees passed the anti-grifting requirement. This may have change overtime (as nonces are incremented for the public accounts). Thus, an entity can submit a group of transactions to mempool that pass anti-grifting checks but lack fees. + - A plausible remedy is to require public accounts `nonce` to match with the known state. E.g., only one transaction using a public account can exist in mempool at a time (by the rules above). This prevents violation of anti-grifting rules. This explicitly forces sequential transactions and disallows pre-queuing. Interestingly, this provides a unified workflow (from user's pov) between public and private states as privacy transactions cannot be pre-queued due to membership proof requirement. + +## 8 Analysis + +### 8.1 Pros + +- Incremental update approach reduces the surface of the account that a program can alter. Programs can directly manipulate an account's balance and data through `AccountDiff` (these updates are applied at the protocol-level with the assistance of `apply_balance_diff` and the program's `update_from_diff`). Additionally, the program can claim an account through the claiming mechanism; this is enforced at the protocol-level. +- Migitates the race condition that affects privacy transactions with respect to public accounts. Updates to a public account used by a privacy transaction (before the privacy transaction is processed) no longer invalidates the proof. Rather, the privacy transaction includes the `AccountDiff` for each public account and these are applied to their corresponding account. This does not guarantee that all such privacy transactions will succeed: a provided `AccountDiff` and current `pre_state` may produce an error when applied to the appropriate `update_from_diff`. + - Better user experience with privacy transactions in LEZ as + - Incremental update approach does not address the analogue race condition for private accounts that are updated. +- LEZ program logic only affects `data` and `balance` entries. +- Simplified chain call construction for developers. Chain calls construct program calls using `account_id`s instead of `pre_state`s. This ensures the sequencer (or privacy preserving circuit) can feed in the up to date account state (from `UpdateFromDiff`). +- Removes attack vectors that malicious parties can exploit within LEZ programs: fewer account entries directly accessible, and `account_id` used for chain calls instead of `pre_state` (thus preventing `is_authorized` from being grifted). +- A proposed fees exploit for public transaction executed in the privacy circuit weakened. Plausibly, a complex program logic that affect public accounts (only) could be performed as a privacy transaction. However, with this construction the sequencer must perform the `UpdateFromDiff` step for each public account. This reduces the cost savings for such behavior. +- `UpdateFromDiff` provides partial updates making fees collectable from some "failed" privacy transactions. + +### 8.2 Cons + +- Increased sequencer overhead for privacy preserving circuits. Sequencer must compute updates to public accounts. Under the current design, the sequencer mere replaces public account states (after validating proof). +- Privacy transaction fees are not constant (within a block). Under the current model, the sequencer validates privacy proofs, replaces the public account states (verbatim), and appends nullifiers and commitments to the appropriate digests. An incremental update to a public account is dependent on the account's `program_owner`'s `update_from_diff`. +- Program devs parse normal function flow from `pre -> post` to `pre -> delta` and `delta + pre' -> post`. This may be difficult for program flow. +- A painful amount of refactoring of the current code base (lez repo and `lez-programs`). +- Previous internal audits and examinations are out of date. + +## 9 Implementation strategy + +- **PR 1 — additive core types only.** `AccountDiff`, `AccountDiffOutput`, `BalanceDiff`, +`apply_balance_diff`, `ProgramCall`/`CallKind`/`read_lee_call` land in `lee_core`/`lee`. + - Add unit tests for these `AccountDiff`, `BalanceDiff`, `apply_balance_diff`. + - *Depends on: nothing.* +- **PR 2 — incremental update wiring.** Program logic is updated to use `AccountDiff`, but the diffs are applied immediate. Thus, producing `post_states` within the circuit an d Protocol-level checks. This enables program and test changes to be done without major changes to the protocol. + - **PR2.1**: Public logic wiring and public tests from `test-methods`. + - **PR2.2**: Privacy logic wiring and privacy tests from `test-methods`. +- **PR 3 — privacy protocol adjustment.** **Update circuit to handle `AccountDiff` logic within the privacy preserving circuit. Public accounts are compressed as `post_state`s before emitting to the sequencer. +- **PR 4 — public protocol adjustment**. Update public Protocol to update accounts using `AccountDiff`. + - **PR4.1**: Update each LEZ program relied on by the indexer. + - **PR4.2**: Indexer/indexer-ffi updates. +- **PR 5 - remove dead code.** + - Remove `AccountPostState` and orphaned unit tests as well as unnecessary `test-methods`. + +The interconnectness of program output logic makes this proposal ambitious with respect to the engineering perspective. Thus, resulting in a PR2 that is hard to parse into smaller pieces. + +## Appendix + +## A.1 `simple_balance_transfer` + +```rust +use std::convert::Infallible; + +use lee_core::{account::{Account, AccountDiff, BalanceDiff, data::Data}, program::{AccountDiffOutput, Claim, ProgramCall, ProgramInput, ProgramOutput, read_lee_call, write_update_from_diff_output}}; + +type Instruction = u128; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: balance, + }, + instruction_words, + ) = match read_lee_call::() { + ProgramCall::Execute(input, instruction_words) => (input, instruction_words), + ProgramCall::UpdateFromDiff { + pre_state, + diff_data, + } => { + let data = update_from_diff(pre_state.clone(), diff_data.clone()) + .expect("update_from_diff should not fail"); + write_update_from_diff_output(&pre_state, &diff_data, &data); + return; + } + }; + + if let Ok([account_pre]) = <[_; 1]>::try_from(pre_states.clone()) { + let diff = AccountDiff { + id: account_pre.account_id, + diff_balance: BalanceDiff::Add(0), + diff_data: None, + }; + let account_post = AccountDiffOutput::new_claimed_if_default( + diff, + account_pre.account.program_owner, + Claim::Authorized, + ); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + vec![account_post], + ) + .write(); + return; + } + + let Ok([sender_pre, receiver_pre]) = <[_; 2]>::try_from(pre_states) else { + return; + }; + + let sender_diff = AccountDiff { + id: sender_pre.account_id, + diff_balance: BalanceDiff::Sub(balance), + diff_data: None, + }; + + let receiver_diff = AccountDiff{ + id: receiver_pre.account_id, + diff_balance: BalanceDiff::Add(balance), + diff_data: None, + }; + + let sender_program_owner = sender_pre.account.program_owner; + let receiver_program_owner = receiver_pre.account.program_owner; + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![sender_pre, receiver_pre], + vec![ + AccountDiffOutput::new_claimed_if_default(sender_diff, sender_program_owner, Claim::Authorized), + AccountDiffOutput::new_claimed_if_default(receiver_diff, receiver_program_owner, Claim::Authorized), + ], + ) + .write(); +} + +fn update_from_diff(_pre_state: Account, _diff_data: Data) -> Result { + Ok(Data::default()) +} +``` + +`update_from_diff` here is an unconditional no-op — this program's own diffs never set +`diff_data`, so this branch is reachable by construction (every `main()` handles both +`ProgramCall` variants) but never actually dispatched to in practice. + +## A.2 `data_changer` + +```rust +use std::convert::Infallible; + +use lee_core::{ + account::{Account, AccountDiff, BalanceDiff, data::Data}, + program::{ + AccountDiffOutput, Claim, ProgramCall, ProgramInput, ProgramOutput, read_lee_call, + write_update_from_diff_output, + }, +}; + +type Instruction = Vec; + +/// A program that modifies the account data by setting bytes sent in instruction. +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: data, + }, + instruction_words, + ) = match read_lee_call::() { + ProgramCall::Execute(input, instruction_words) => (input, instruction_words), + ProgramCall::UpdateFromDiff { + pre_state, + diff_data, + } => { + let data = update_from_diff(pre_state.clone(), diff_data.clone()) + .expect("update_from_diff should not fail"); + write_update_from_diff_output(&pre_state, &diff_data, &data); + return; + } + }; + + let Ok([pre]) = <[_; 1]>::try_from(pre_states) else { + return; + }; + + // `Data`'s own fallible `TryFrom>` enforces the account data size limit here, at + // diff-construction time, rather than deferring it to `update_from_diff`. + let data: Data = data + .try_into() + .expect("provided data should fit into data limit"); + + let diff = AccountDiff { + id: pre.account_id, + diff_balance: BalanceDiff::Add(0), + diff_data: Some(data), + }; + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pre], + vec![AccountDiffOutput::new_claimed(diff, Claim::Authorized)], + ) + .write(); +} + +fn update_from_diff(_pre_state: Account, diff_data: Data) -> Result { + Ok(diff_data) +} +``` + +Unlike `simple_balance_transfer`, this one's `update_from_diff` *is* dispatched to in +practice — it's the only currently-migrated program whose `diff_data` is ever `Some`. The +size-limit check happens once, in `main()`, at diff-construction time; by the time +`update_from_diff` runs, `diff_data` is already a validated `Data`, so returning it directly +is infallible. \ No newline at end of file diff --git a/.claude/lez-fees/PLAN.md b/.claude/lez-fees/PLAN.md new file mode 100644 index 000000000..6dbf30457 --- /dev/null +++ b/.claude/lez-fees/PLAN.md @@ -0,0 +1,150 @@ +# LEZ Fee Subsystem — Implementation Plan + +Phase-2 plan implementing `.claude/lez-fees/SPECS.md` per erhant's decisions (no backwards compat; wire prefix bump to `/LEE/v0.4/`; everything restarts from genesis; producer pubkey in header; system txs fee-exempt; admission checks yes; genesis fee-exempt). Companion to `ANALYSIS.md` (file:line anchors there). + +> **Superseded (2026-08-11):** wherever this plan says "wire v0.4" / prefix bump — erhant's final ruling is that ALL version prefixes stay at their committed values (`/LEE/v0.3/`, `/LEZ/v0.3/`); the fee wire-format changes ship under unchanged tags and version bumps happen in a separate later PR. T4 implemented this; T5/T6 must NOT bump prefixes. + +## Assumptions & defaults chosen (TBA isolation) + +Every TBA is isolated behind a single named seam; swapping the decision later touches only that seam (plus, at worst, one additive wire field — acceptable since compat is a non-goal). + +| # | TBA | Seam (single point of change) | Provisional default built now | Must we wait? | +|---|-----|-------------------------------|-------------------------------|----------------| +| D1 | Q1 payer authorization (public) — **ANSWERED 2026-08-11** | wire-format fee authorization (T4) + `fee_core::authorize_payer` seam | Fees team ruling: the payer is any account whose **fee authorization** accompanies the tx — explicit designation plus a signature (or a program authorization) over the fee fields and the exact transaction they cover, per the transaction-format spec. Payer MAY be a signer, MAY be a third party outside the witness set (sponsored txs). MUST be designated explicitly, never inferred from the witness set. → T4 adds the fee-authorization structure to the wire format (payer designation is already a signed `Message` field; a third-party payer supplies a fee witness — payer pubkey + signature over the same tx hash); T4 also replaces fee_core's provisional payer∈signers rule (T1 shipped it under `// TBA(Q1)`) with authorization-validity checked at the wire/validation layer. Program authorization: defer behind the same seam until the ledger spec defines it. | Resolved. | +| D2 | Q2 program-deployment pricing — **ANSWERED 2026-08-11, then SUPERSEDED 2026-08-12** | `fee_core` deployment policy arm + deployment wire fields (T4 train) | Tokenomics ruled "folded into the public fee model" and T4 shipped the wire fields + authorization accordingly. **Standing decision (erhant 2026-08-12): deployments stay fee-EXEMPT** (storage-cap-counted, uncharged, `TBA(deployment-replay)`) until the replay-protection design is settled with the spec team — deployment messages have no nonces, so a charged-but-failed deployment would be re-includable forever. NOTE: `fee_core::deployment_policy()` still declares `PricedAsPublic` (the intent); the transition does not consume it — do not read fee_core alone as the shipped behavior. | Charging later = flip the marked site + add replay protection (uniform account_ids/nonces preferred). | +| D3 | Q3 + Q5-private (private payer & anti-replay) — **still OPEN, contested** | `fee_core::private_fee_payer(&PrivacyPreservingTransaction) -> Result` + settlement's nonce-advance hook | Private wire format gains a fixed-size `payer: AccountId` field now (needed anyway for the constant-size envelope; fixed 32 bytes, no size variance). Default rule: `payer` MUST be one of the tx's public signers (≥1 public signature required). Nonce-advance-on-settlement applies to that public account, same as Q5-public. Status 2026-08-11: tokenomics team calls private fee *funding* (burner accounts / relayer / fee program) out of scope for the fee spec; erhant disagrees and is pursuing it. INCREMENTIAL §6's collateral-account model is a competing answer. Spec side of Q5 now pinned: "an included tx consumes its replay protection whether it succeeds or reverts" (full invalid-vs-reverted design owned by execution/ledger specs). | Partially. The *wire field and hook* are safe now; the *rule* only changes this function + wallet UX later. **Shipped interim (T8, INCREMENTIAL parking): private txs are NOT charged and NOT payer-validated at all** — `authorize_private_payer` never runs; they are accepted uncharged and cap-counted. The earlier "fully-shielded txs rejected at fee-validity" default was superseded by the uncharged interim. | + +## INCREMENTIAL.md exposure (added 2026-08-11) + +`.claude/lez-fees/INCREMENTIAL.md` proposes account-state diffs (`AccountDiff` / `update_from_diff`) to fix the privacy-tx race condition. Its adoption status is **undecided**; if adopted it changes fee-relevant semantics. Tasks below marked ⚠️INCR carry the exposure; do not start the exposed *private-path* work until the ruling lands. + +- **T5 ⚠️INCR (highest):** the privacy-tx journal gains `public_diffs` (compressed per-account) — the constant-size envelope's padding target and T3's byte model both change. T3's *methodology* (measured deltas) transfers; its numbers must be re-measured post-INCREMENTIAL. +- **T8 ⚠️INCR (high, private path only):** sequencer replays `update_from_diff` per public account touched by a privacy tx → (a) valid-proof-but-failed-update becomes a charged-failure class (fits our revert-keeps-fee semantics, but the classification rules are INCREMENTIAL §6's cases 2–3); (b) that replay is *variable sequencer execution work* not covered by a flat `PRIVATE_VERIFY_GAS` — either the fee model adds a capped/constant diff-replay allowance (preserves privacy invariant 3) or private fees stop being constant (INCREMENTIAL §8.2's stated con; a privacy regression the fee spec must rule on). +- **T2 (low):** if `update_from_diff` replay is metered, the metering plumbing gains a second consumer; the current cycle plumbing is compatible as-built. +- **T10 (medium):** INCREMENTIAL §7's mempool collision/anti-grift rules (nullifier-dedup by fee, nonce-dedup by fee, payable-fees admission requirement) extend the Q9 admission checks; design T10's admission layer so these rules slot in. +- **T11 (medium):** wallet proving/submission flow changes under diffs (account_ids instead of pre_states); fee estimation for privacy txs depends on the T8 ruling. +- **Q3 interaction:** INCREMENTIAL §6 assumes a collateral account for private fees — a competing/companion answer to the still-open Q3. One decision should cover both. + +Other assumptions: + +- **Fee state lives inside `V03State`** (new fields + the destructure guard at `lee/state_machine/src/state/mod.rs:306` forces every constructor/consistency site to acknowledge them). Rationale: both `ChainState` tiers and the indexer's scratch-clone path carry `V03State` verbatim; a wrapper struct would touch every one of those signatures for no benefit. Snapshot format breaks → testnet reset (approved). +- **`height` ≡ `block_id`**, genesis block (id 1) is fully fee-exempt (all its txs are system txs per Q6/Q10); the first fee-charging block is id 2; fee state is genesis-initialized per SPECS §Genesis. +- **Out-of-gas**: risc0 `session_limit` is a hard user-cycle limit that errors; we wrap it in a structured `LeeError::OutOfGas { consumed_at_limit }` detected via a dedicated executor wrapper (not string matching at call sites) and charge the full `gas_limit`. +- **Verification protocol** (per erhant's standing instructions): subagents run `cargo check -p `, `cargo +nightly fmt`, targeted `cargo test -p ` (with `RISC0_DEV_MODE=1` and `--all-features` where sequencer_core is involved) — never the full suite, never commit. erhant runs the suite and commits. +- Clippy bar: every task must leave `cargo clippy -p --all-targets --all-features -- -D warnings` clean; `taplo fmt` for Cargo.toml edits. + +## Task graph + +``` +Phase A (parallel): T1 fee_core │ T2 metering │ T3 size-measure tool +Phase B (wire v0.4): T4 public fields ← T1 │ T5 private envelope ← T3 │ T6 producer header +Phase C (consensus): T7 FeeState-in-state ← T1 │ T8 block transition ← T1,T2,T4,T5,T6,T7 │ T9 builder ← T8 +Phase D (edges, parallel after C): T10 RPC/admission │ T11 wallet │ T12 indexer/FFI/explorer │ T13 genesis+configs +Phase E: T14 e2e integration tests ← all +``` + +--- + +### T1 — `fee_core` pure crate + +**Goal:** All fee arithmetic and state types, dependency-free, cross-checked against SPECS Annex A/B. +**Files:** new `lez/fee_core/` (workspace member in root `Cargo.toml`): `params.rs` (all constants incl. genesis `MAX = 2·TARGET` validation), `state.rs` (`FeeState { base_fee_exec, base_fee_stor, escrow: u128, window: [u128; 50] + cursor, payout_carry, }` — Borsh; height stays the chain's `block_id`), `assess.rs` (`gas_stor`, `fee_reserve`, `fee_actual_base` over a `FeeTxView` enum abstracting `LeeTransaction`), `update.rs` (`next_base_fee`), `distribute.rs` (window push/payout/carry), `validity.rs` (static tx/block checks, `authorize_payer` D1 seam, `private_fee_payer` D3 seam, D2 policy arm), `error.rs`. +**Deps:** none. +**Verify:** unit tests transcribing SPECS Annex B's cross-check driver: the 8-block scenario table (exact printed tuples from the spec) + the LCG 10k-block fuzz final line as golden values; property tests for invariants 1–5 (conservation, bounded move, saturation, carry < 50, u64 fit); `cargo test -p fee_core`. + +### T2 — metering plumbing (lee) + +**Goal:** Deterministic per-tx user-cycle metering with a cumulative budget across chained calls; structured out-of-gas. +**Files:** `lee/state_machine/src/program/mod.rs` (`Program::execute(..., cycle_budget: u64) -> Result<(ProgramOutput, u64 /*cycles*/)>`; delete `MAX_NUM_CYCLES_PUBLIC_EXECUTION`, keep a protocol ceiling = `MAX_GAS_EXEC` for non-fee callers), `lee/state_machine/src/error.rs` (`OutOfGas` variant; wrapper distinguishing session-limit bail from other executor errors), `lee/state_machine/src/validated_state_diff/mod.rs` (`from_public_transaction(..., gas_limit) -> Result<(Self, cycles)>`: thread `remaining = gas_limit − used` into each chained call, sum user cycles), `lez/common/src/transaction.rs` (propagate cycles out of `validate_on_state`/`execute_on_state` — return `(diff, ExecutionOutcome)`). +**Deps:** none (merges before or in parallel with T4; call sites temporarily pass `MAX_GAS_EXEC` as the budget until T8 wires real `gas_limit`). +**Verify:** targeted tests in lee: a known guest (`test_methods::simple_balance_transfer`) returns a *stable* nonzero cycle count (pin the exact number — this doubles as the determinism regression test); budget below that count yields `OutOfGas`; chained-call test sums across sessions and halts mid-chain. `RISC0_DEV_MODE=1 cargo test -p lee `. + +### T3 — private envelope size measurement + constant re-pin + +**Goal:** Real numbers for `PRIVATE_GAS_STOR` (envelope + proof + padded payload), replacing the spec's zero-envelope-overhead provisional value; a reusable measurement test so drift fails loudly. +**Files:** new test/bin in `lee/state_machine` (or `tools/`): construct maximal private txs (max public actions? — no: measure the *canonical padded form* defined in T5; this task first produces the raw component-size report: Borsh `InnerReceipt` real proof size — must confirm 223,551 — sig+pubkey pair size, per-`PrivateAction` size, enum tag/vec-len overhead). +**Deps:** none for measurement; T5 consumes the numbers. (A real STARK receipt is needed once — generate with `RISC0_DEV_MODE=0` for the one measurement, or reuse a committed fixture from `test_fixtures/`; flag runtime cost to the orchestrator.) +**Verify:** the measurement test itself asserts component sizes; report numbers back for the spec team to re-pin (feeds the SPECS TODO). + +### T4 — wire v0.4: public fee fields + +**Goal:** `payer: AccountId`, `gas_limit: u64`, `tip: u64`, `max_fee: u128` inside the signed public `Message`; prefix bumps. +**Files:** `lee/state_machine/src/public_transaction/message.rs` (fields + `PREFIX` → `/LEE/v0.4/…`), `transaction.rs`, all `Message::try_new`/`new_preserialized` call sites (wallet, programs facades, test_utils, sequencer's clock/deposit builders — mark system-tx construction with zeroed fee fields), `lez/common/src/block.rs` + private/deployment prefixes bumped in the same sweep (one atomic version bump for every domain prefix, incl. `HashableBlockData` PREFIX), pinned-hash tests updated. +**Deps:** T1 (for the validity checks tests reference); coordinates with T5/T6 (single wire-break PR train). +**Verify:** encoding-roundtrip + updated pinned-hash tests (`hash_public_pinned` etc. — recompute expected bytes); `cargo check -p lee -p common` and the tx-level unit tests. + +### T5 — wire v0.4: constant-size private envelope (+ D3 payer field) + +**Goal:** Every private tx serializes to exactly `PRIVATE_GAS_STOR` bytes (prod and dev mode). +**Files:** `lee/state_machine/src/privacy_preserving_transaction/` — new canonical envelope: fixed `payer: AccountId` field (D3), fixed-capacity encodings (cap + pad `public_actions`/`nonces`/`private_actions`/signature vec to protocol maxima — the caps become protocol constants; unpadded-payload bound `PRIVATE_PAD_BYTES` enforced in wire validity), proof slot padded to `PROOF_BYTES` (dev-mode fake receipts padded to the same slot — verifier strips padding before `borsh::from_slice::`), wire-size equality check in `transaction_stateless_check` (`lez/common/src/transaction.rs:57`). +**Deps:** T3 (numbers), T4 (prefix train). +**Verify:** tests: any two constructed private txs (incl. dev-mode proof, zero vs max public actions) serialize to identical length == constant; roundtrip; padded-proof still verifies. Note for spec team: final `PRIVATE_GAS_STOR`/max-field caps go back into SPECS. + +### T6 — producer in the block header + +**Goal:** `producer: lee::PublicKey` header field; header signature verified against it in consensus; producer account derivable. +**Files:** `lez/common/src/block.rs` (`BlockHeader`, `HashableBlockData` — producer inside the hashed data), `lez/chain_state/src/apply.rs::validate_against_tip` (verify `is_signed_by(&header.producer)`), `into_pending_block` signature, sequencer construction sites, `is_signed_by` callers in `cross_zone_verifier`/indexer (can now use the embedded key; pinned-peer-key checks compare against it). +**Deps:** T4 (same wire-break train). +**Verify:** unit tests: valid block passes; wrong-key signature parks; tamper test updated. `cargo test -p chain_state -p common `. + +### T7 — FeeState into consensus state + genesis + +**Goal:** Fee state persisted/reorged/finalized with everything else. +**Files:** `lee/state_machine/src/state/mod.rs` (`V03State { …, fee_state: FeeState }` — hits the line-306 destructure guard; `V03State::new()` initializes per SPECS §Genesis; accessor + `apply` hooks), `lez/fee_core` re-exported through `lee`; snapshot code needs no change (Borsh derives). +**Deps:** T1. +**Verify:** state roundtrip test incl. fee fields; genesis-values test; `cargo check -p lee -p chain_state -p sequencer_core -p indexer_core` (the destructure guard will enumerate every site to fix — that compile pass *is* the verification). + +### T8 — block transition (the core task) + +**Goal:** SPECS `block_transition` semantics in the shared apply path, used identically by sequencer follow/reconstruction and indexer replay. +**Files:** `lez/chain_state/src/apply.rs::apply_block_to_state` — restructure to: (1) static fee-validity + storage cap (system txs — clock tail, marked deposit-mints/dispatches, genesis block, D2 deployments — exempt from fees *and* caps per Q6); (2) per-tx reserve → execute (T2 cycles, `gas_limit` budget) → settle (charge `f_base + tip`, release remainder, **advance payer nonce at settlement** so charged failures can't replay — Q5); tx-level failure keeps fee, discards diff, block stays valid; reserve failure ⇒ block invalid; cumulative `MAX_GAS_EXEC` check as cycles land; (3) escrow/window/payout via `fee_core::distribute`, producer credit (`AccountId::from(&header.producer)`) last; (4) `next_base_fee` both resources; invariant checks (debug_assert + release-mode consensus-fault check for payout ≤ escrow). `ingest_error.rs` new variants. System-tx identification: clock = last-tx equality (as today, against v0.4 canonical form); deposit-mints/dispatches need a consensus-visible marker — use their existing structural signatures (`extract_bridge_deposit_id`, `extract_cross_zone_dispatch` — already pure functions over tx content) hoisted into `common` so validators classify identically. +**Deps:** T1, T2, T4, T5, T6, T7. +**Verify:** table-driven tests mirroring fee_core's golden scenario but through real `Block`s with mock-cycle guests; reorg test: fee state reverts with head rewind (`chain.rs` two-tier test); charged-failure test (revert keeps fee, nonce advanced, replay of same tx now nonce-fails); private-equality invariant test (all private txs in a block pay the same). `RISC0_DEV_MODE=1 cargo test -p chain_state --all-features`. + +### T9 — builder parity + +**Goal:** `build_block_from_mempool` produces only blocks T8 accepts, and prices selection. +**Files:** `lez/sequencer/core/src/lib.rs` — budget selection by `gas_limit`/`data_bytes` against caps (replacing count/size-only limits; `max_block_size` remains as framing bound ≥ `MAX_GAS_STOR` + overhead), reserve-check before inclusion (drop tx if payer can't cover — that's the *builder's* prerogative; included-then-failed still charged), stop dropping execution-failures silently for *user* txs (include + charge, matching T8), system txs unchanged and exempt, admission of both tx streams unified through the same fee_core checks used by T8. +**Deps:** T8. +**Verify:** sequencer_core tests (`--all-features`, per memory note): block full-of-gas boundary, tip accounting, producer credit visible only next block, builder-vs-apply agreement (a built block re-applies cleanly through `apply_block_to_state` from the parent state — the key property test). + +### T10 — RPC admission + fee queries + +**Goal:** Q9 admission checks; clients can price txs. +**Files:** `lez/sequencer/service/src/service.rs` (admission: static fee-validity, `max_fee ≥ current reserve`, payer-balance ≥ reserve against head state), `lez/sequencer/service/rpc/src/lib.rs` (+ `get_fee_state`/`get_base_fees` returning base fees, next-block estimates, private fee quote). +**Deps:** T8 (fee state readable), T4. +**Verify:** service-level unit tests for accept/reject; `cargo test -p sequencer_service `. + +### T11 — wallet + +**Goal:** Wallet builds fee-valid v0.4 txs. +**Files:** `lez/wallet/src/` (tx construction: payer defaults to first signer, `gas_limit` via dry-run estimate RPC or local executor + margin, `max_fee` from queried base fees + headroom factor, `tip` flag; delete vestigial `GasConfig` in `config.rs:22`), `lez/wallet-ffi` (surface fee params), keycard path needs no applet change (signs the 32-byte message hash; fee fields ride inside `Message`). +**Deps:** T4, T10. +**Verify:** wallet unit tests for fee computation; `cargo check -p wallet -p wallet-ffi`. + +### T12 — indexer / FFI / explorer + +**Goal:** Fee visibility downstream. +**Files:** `lez/indexer/core` (replay already correct via T8; expose fee state + per-block fee receipts — store `BlockReceipt`-like record per block), `lez/indexer/service` + `lez/indexer/ffi` (query methods; cbindgen regenerates `indexer_ffi.h` via build.rs), `lez/explorer_service` (display base fees/fees per tx). Downstream module flake bumps (`lez-indexer-module`, `lez-explorer-ui`) are **out of repo scope** — note for erhant. +**Deps:** T8. +**Verify:** indexer core tests (fee state matches sequencer's after replay — extend existing accept_block tests); `cargo check -p indexer_ffi` regenerating the header. + +### T13 — genesis, configs, ops + +**Goal:** Coherent deploy story from genesis. +**Files:** `lez/configs/*`, `lez/{sequencer,indexer}/service/configs/*`, `lez/testnet_initial_state` (fund testnet accounts amply for fees), docker compose docs, `Justfile` (`just clean` already resets stores — sufficient given no-migration), `docs/`. +**Deps:** T8–T12. +**Verify:** config deserialization tests; `cargo check` workspace. + +### T14 — end-to-end integration tests + +**Goal:** Lifecycle proof under `RISC0_DEV_MODE=1`. +**Files:** `integration_tests/` — scenarios: fee lifecycle (reserve/settle/refund visible in balances), congestion moves base fees up/down across blocks, private txs pay identically & fit ≤4/block, OOG tx charged at limit + nonce advanced, producer payout smoothing over 50 blocks (shortened via test constants? **No** — constants are protocol-fixed; run 60 blocks in-test), multi-sequencer producer credit to the right key, admission rejections. Rebuild committed artifacts if core crates changed guest-visible code (`just build-artifacts` — coordinate with erhant, slow). +**Deps:** everything. +**Verify:** each new test run individually by its author subagent; full suite is erhant's. + +## Sequencing recommendation + +Three PR trains on a feature branch (rebase-only, Conventional Commits): **(1)** T1+T2+T3 (no wire impact, land early, reviewable in isolation); **(2)** T4+T5+T6+T7 as the single coordinated v0.4 wire/state break; **(3)** T8+T9, then D-phase tasks in parallel, T14 last. The three TBA seams (D1/D2/D3) each carry a `// TBA(Qn):` marker comment so the later decisions are a grep away. + +**Open items to relay to the spec team** (from this plan): Q6 deviation from invariant 6 (system-tx exemption); final `PRIVATE_GAS_STOR`/field-cap numbers from T3/T5; the Q5 nonce-advance-on-settlement rule; deployment-tx pricing (Q2) and private-payer rule (Q3) whenever ready. diff --git a/.claude/lez-fees/SPECS.md b/.claude/lez-fees/SPECS.md new file mode 100644 index 000000000..9b2d8a8c8 --- /dev/null +++ b/.claude/lez-fees/SPECS.md @@ -0,0 +1,984 @@ + + +# Introduction + +LEZ is an execution zone on the Logos (Bedrock) chain supporting public transactions, executed by the sequencer, and private transactions, verified from a RISC Zero proof. This document specifies the LEZ fee subsystem: fee-related transaction validity, what each transaction pays, who pays and who is paid, how prices adjust, and the exact integer arithmetic and state transition for all of it. + +**Summary.** Execution and storage are priced by two independent EIP-1559-style markets in pure integer arithmetic. Every private transaction is charged identical, protocol-fixed resource quantities, so within a block all private transactions pay the same fee and fees reveal nothing about private computation. Fees are held from payer accounts as an upfront reservation, settled to the actual amount after execution, and accumulated in an escrow that pays block producers in fiftieths: one share in the collecting block and one in each of the 49 blocks after it. Tips go directly to the producer. Nothing is burned. + +## Objectives + +1. Price congestion on the two resources a block consumes: execution (zkVM cycles) and storage (bytes posted to Bedrock). +2. Preserve privacy: fee amounts must not distinguish one private transaction from another. +3. Be deterministic and implementable: integer-only arithmetic, concrete values for every parameter (one provisional value is flagged inline), identical results across implementations and build profiles. +4. Operate independently of L1 price signals: the mechanism reads nothing from Bedrock. + +## Scope and non-goals + +This specification defines the fee subsystem and its ledger effects. It consumes two canonical inputs per transaction, a metered cycle count and a serialized byte length, whose producing algorithms belong to the LEZ execution and wire-format specifications (see *Interfaces*). Out of scope: the Bedrock fee market (L1 posting is funded out-of-band by the sequencer's node wallet); the mechanism by which private users fund their fee account without linking identities, defined in a companion specification, linked here once it exists; any such mechanism acts as the payer through the fee authorization defined under *Transactions*, and this document requires only that the authorization is valid and the reservation succeeds; sequencer reward distribution beyond the per-block payout defined here; data availability; proof aggregation. + +# Overview + +A transaction's fee is gas times price, summed over two resources. Each resource has its own base fee. Usage above target always raises the base fee by at least one unit; usage below target never raises it and lowers it subject to integer rounding and the minimum. Either move is capped at 12.5% per block. Public transactions may attach a tip. + +```mermaid +flowchart LR + TX[Transaction] --> VAL[Fee-validity] --> RES[Reserve
held from payer] --> EXE[Execute
metered] --> SET[Settle
actual fee] + SET -->|base fees| ESC[Escrow] -->|fiftieths over
50 payouts| PAY[Producer payout] + SET -->|tips| PAY + SET -->|gas used| CTL[Base-fee update
for next block] +``` + +**Two markets.** Execution gas measures sequencer work: metered zkVM cycles for public transactions, a fixed verification cost for private ones. Storage gas measures bytes the block contributes to the Bedrock post: serialized size for public transactions, the fixed serialized size of any private transaction. The markets are independent: a block can be execution-full and storage-light or the reverse, and each base fee responds only to its own resource. + +**Reserve, execute, settle.** The exact execution fee is known only after execution, so payment works in two steps. Before a transaction executes, its worst-case fee (its `gas_limit` priced at the current base fees, plus storage and tip) is held from the payer. After execution, the actual fee is computed from metered cycles and the unused part of the reservation is released back to the payer. Payment is guaranteed before any work is done, and execution sees a deterministic balance. + +**Privacy.** Every private transaction is charged `PRIVATE_VERIFY_GAS` execution gas and `PRIVATE_GAS_STOR` storage gas, constants of the protocol version, and carries no public fee fields. Its fee depends only on the current base fees, never on its contents; within a block, all private transactions pay the same amount. + +**Distribution.** Settled base fees accumulate in an escrow. Each block's base revenue is paid out in fiftieths: one share in the collecting block and one in each of the 49 blocks after it, with the integer-division remainder carried forward, so every unit collected is eventually paid. Smoothing also made all tested always-on self-dealing strategies unprofitable: inflating the base fee with junk pays that fee now and recovers it slowly, mostly to other producers (adaptive strategies remain an open risk; see *Manipulation resistance*). No tokens are burned: LEZ has no minting mechanism to compensate a burn. + +**Worked example.** *At genesis both base fees are 8 atomic units. A public transaction of 50,000 cycles carrying 200 bytes pays 50,000·8 + 200·8 = 401,600 atomic units (about 0.0004 LGO). Any private transaction pays 409,764·8 + 224,063·8 = 5,070,616 atomic units (about 0.005 LGO), identical for all of them in that block.* + +# Protocol + +## Conventions + +All quantities are unsigned integers. Monetary amounts are denominated in the atomic unit, with 1 LGO = 10⁹ atomic units. The total supply is 10¹⁹ atomic units. + +Base fees, gas amounts, and per-resource fee products MUST fit in 64 bits; the parameter caps below guarantee this for every valid block. Fee totals, balances, revenue totals, and every intermediate product MUST be computed in at least 128-bit width. Because the total supply is below 2⁶⁴, no account balance or credit can overflow 128-bit arithmetic. Implementations MUST NOT rely on language-level overflow behavior, and MUST produce identical results in checked and unchecked build profiles. The only arithmetic division in the protocol is floor division of non-negative integers; no other rounding exists and floating point MUST NOT appear anywhere. + +## Interfaces + +The protocol consumes two deterministic per-transaction inputs. Their producing algorithms are consensus-critical but external to this document: + +- **Metered cycles** (public transactions): the zkVM executor's deterministic user-cycle count for the transaction's execution trace, with metering halted at the transaction's `gas_limit`. Defined by the LEZ execution specification. This count is the gas unit; there is no separate opcode weighting. +- **Serialized length** (public transactions): the byte length of the transaction's canonical serialization as it appears in the block payload posted to Bedrock. Defined by the LEZ wire-format specification. It is at least 1 for any real transaction. + +For private transactions, the wire-format specification MUST produce a constant serialized size equal to `PRIVATE_GAS_STOR` (envelope, proof, and padded payload included), for every private transaction, and together with the proof validity rules MUST enforce the unpadded payload bound `PRIVATE_PAD_BYTES`. Block framing and other block-level overhead bytes are not charged to transactions and are borne by the producer. The execution, wire-format, and ledger specifications named here are part of the protocol version; their exact revisions MUST be pinned when protocol version 1 is frozen (tracked with the wire-size TODO under *Parameters*). + +## Transactions + +Fee-relevant transaction fields. *Signed* fields are chosen and authorized by the sender; *derived* fields are produced by serialization. Fields marked absent do not appear in the private wire format; implementations represent them internally as zero. + +| Field | Type | Source | Public | Private | +| --- | --- | --- | --- | --- | +| `payer` | AccountId (ledger spec) | signed | fee account debited | fee account debited | +| `gas_limit` | u64 | signed | execution bound; metering halts here | absent | +| `data_bytes` | u64 | derived | canonical serialized length | fixed at `PRIVATE_GAS_STOR` by the wire format | +| `tip` | u64 | signed | priority payment, MAY be 0 | absent | +| `max_fee` | u128 | signed | cap on `fee_reserve` | absent | + +`cycles` is not a transaction field. It is part of the execution outcome: the metered cycle count the executor produces while running the transaction, with metering halted at `gas_limit`. An outcome with `cycles` above `gas_limit` is an executor defect and a consensus fault, not a transaction validity failure. A private transaction's unpadded payload length is likewise not a fee-relevant field: the proof and wire-format validity rules MUST enforce that the payload fits within `PRIVATE_PAD_BYTES`, and the fee subsystem assigns every externally valid private transaction the fixed storage gas `PRIVATE_GAS_STOR`. + +The payer is any account whose fee authorization accompanies the transaction: an explicit designation plus a signature, or a program authorization, over the fee fields and the exact transaction they cover, defined by the transaction-format specification. The payer MAY be one of the transaction's signers and MAY be a third party outside the witness set, which permits sponsored transactions; the fee subsystem requires only that the authorization is valid and that the reservation succeeds. The payer MUST be designated explicitly, never inferred by convention from the witness set. + +`max_fee` bounds the payer's exposure: a transaction whose fee terms were authorized at low base fees cannot be included later at prices whose reservation exceeds its cap. + +Program deployment transactions are public transactions for fee purposes: the deployed program's bytes are part of `data_bytes` through the canonical serialization, and execution is metered like any public transaction. A single transaction therefore cannot deploy a program larger than `MAX_GAS_STOR` bytes. Signing a private transaction authorizes the fee computed at its inclusion block; there is no consensus-enforced private fee cap, and the only bound is the payer account's balance. Wallets can bound exposure operationally because the private fee is computable from public state before submission. + +## State + +| Name | Type | Meaning | +| --- | --- | --- | +| `base_fee_exec` | u64 | Execution base fee for the current block | +| `base_fee_stor` | u64 | Storage base fee for the current block | +| `escrow` | u128 | Settled base revenue not yet paid out | +| `window` | [u128; 50] | Base revenue of the last 50 blocks, zero-initialized | +| `payout_carry` | integer in [0, 49] | Payout division remainder | +| `height` | u64 | Block height; an increment at 2⁶⁴ - 1 is a consensus fault | + +## Fee-validity + +Fee-validity splits into **static** rules, checked before execution, and **dynamic** rules, enforced during the transition as metered cycles become known. The split is needed because `cycles` only exists after execution. + +A transaction is statically fee-valid under this specification iff: + +- **Private:** `tip`, `max_fee`, and `gas_limit` are all 0 (absent). Its canonical wire size equals `PRIVATE_GAS_STOR` and its unpadded payload fits `PRIVATE_PAD_BYTES`; both are enforced by the wire-format and proof validity rules, not by the fee subsystem. +- **Public:** 1 ≤ `data_bytes` ≤ `MAX_GAS_STOR`; `gas_limit` ≤ `MAX_GAS_EXEC`; and `fee_reserve` ≤ `max_fee` at the block's opening base fees. + +A transaction MUST also satisfy every validity condition imposed by the execution, proof, wire-format, ledger, and consensus specifications. A private transaction with an invalid proof is an invalid transaction, not a reverted one: it cannot be included, and a block containing it is invalid. + +A block is fee-valid iff every transaction is statically fee-valid, the storage total is within its cap (serialized lengths are known before execution), every fee reservation succeeds, and the cumulative metered execution gas stays within its cap as transactions execute: + +```python +sum(gas_stor(tx) for tx in txs) <= MAX_GAS_STOR # static, before execution +cumulative metered cycles <= MAX_GAS_EXEC # dynamic, per Block transition +``` + +One invalid transaction invalidates the whole block, and a block whose cumulative executed cycles exceed the cap at any transaction is rejected in full. The caps apply to consumed gas; block builders SHOULD budget by `gas_limit` when selecting transactions. Totals MUST be accumulated in widened arithmetic (the per-transaction bounds make each term fit u64, but the sums are checked, never wrapped). The resources are not fungible: a surplus in one MUST NOT offset a deficit in the other. + +System transactions enumerated by the wire-format specification, such as the clock transaction, are outside the fee subsystem: no payer, no fee, and no contribution to either gas total. Like block framing, they are overhead borne by the producer. The exemption applies only to the enumerated kinds; every other included transaction is reserved and settled like any other. + +Two consequences follow: since every public transaction has `data_bytes` ≥ 1 and every private transaction has fixed storage gas, a block contains at most `MAX_GAS_STOR` transactions and there are no zero-cost transactions; and at most ⌊`MAX_GAS_STOR`/`PRIVATE_GAS_STOR`⌋ = **4 private transactions fit in a block** under the current proof size. + +## Fee assessment + +Two amounts are defined per transaction, both at the block's opening base fees: + +```python +fee_reserve(tx) = gas_limit * base_fee_exec + data_bytes * base_fee_stor + tip # public +fee_reserve(tx) = PRIVATE_VERIFY_GAS * base_fee_exec + PRIVATE_GAS_STOR * base_fee_stor # private + +fee_base(tx) = gas_exec(tx) * base_fee_exec + gas_stor(tx) * base_fee_stor +fee_total(tx) = fee_base(tx) + tx.tip +``` + +with gas given by: + +| | `gas_exec` | `gas_stor` | +| --- | --- | --- | +| Public | `cycles` from the execution outcome (≤ `gas_limit`, an executor guarantee) | `data_bytes` | +| Private | `PRIVATE_VERIFY_GAS` | `PRIVATE_GAS_STOR` | + +`fee_reserve` is the amount held before execution; only the execution component is uncertain in advance, so the reserve prices `gas_limit` where the actual fee prices `cycles`. For private transactions both gas quantities are constants, so the reserve equals the actual fee. A transaction that fails, reverts, or halts at its limit is charged for the cycles consumed to that point and its full storage gas (its bytes are posted regardless); the difference between reserve and actual fee is a released reservation, not a refund of work done. Spam always pays for the resources it consumes. An included transaction consumes its replay protection, defined by the ledger specification, whether it succeeds or reverts; the same fee authorization is never charged twice. The producer MAY use tips to choose an ordering; the protocol does not constrain that choice, but the order encoded in the block is, like all block contents, consensus data. + +## Base-fee update + +Each resource updates independently after every block. With current base fee `b`, gas used `g`, target `T`, denominator `d`, and bounds `lo`, `hi`: + +```python +def next_base_fee(b, g, T, d, lo, hi): + if g > T: + deviation = min(g - T, T) + delta = max(1, (b * deviation) // (T * d)) # 128-bit product + return min(hi, b + delta) + if g < T: + deviation = min(T - g, T) + delta = (b * deviation) // (T * d) # 128-bit product + return max(lo, b - delta) + return b +``` + +Properties, each of which an implementation MUST preserve: + +- **Bounded:** the per-block move is at most `max(1, b // d)` in either direction, which is ±12.5% at `d` = 8 (the deviation clamp enforces this bound for any parameterization). +- **Live upward:** the `max(1, ·)` term guarantees a rise of at least one unit whenever usage exceeds target, from any price. Without it, integer rounding pins low prices forever under congestion. +- **Asymmetric at small prices, by design:** the down-step has no matching minimum, so small deviations below target can round `delta` to zero and leave the price unchanged. One unit above target always moves the price; one unit below target usually does not. The resulting mild upward bias at low prices is accepted in exchange for guaranteed liveness. +- **Saturating:** the result is clamped to `[lo, hi]`. The caps make boundary behavior defined and identical across implementations; they are not reachable under realistic demand. + +## Revenue distribution + +Per block, after all transactions have settled: + +1. `revenue_base` (the block's total settled `fee_base`) is credited to `escrow` and pushed into `window`, evicting the oldest of its 50 slots. +2. The payout is the window average with the division remainder carried across blocks: + +```python +numerator = sum(window) + payout_carry +payout = numerator // SMOOTHING_WINDOW +payout_carry = numerator % SMOOTHING_WINDOW +escrow -= payout # payout <= escrow always holds; see below +``` + +Then the producer's account is credited with `payout + revenue_tip`. This credit is the last ledger effect of the block, so tips earned in a block cannot fund a transaction in that same block. + +This is exact amortization: each block's base revenue contributes to exactly 50 consecutive payouts, starting with the collecting block, so a revenue pulse is fully distributed after its 50th payout and nothing is stranded. Escrow records base revenue not yet paid; the window and carry determine the payout schedule, and window entries are historical revenue values, not individually unpaid balances. Because the window starts zero-filled and the carry makes the cumulative payout exactly ⌊Σ window-sums / 50⌋, cumulative payouts never exceed cumulative revenue and `payout ≤ escrow` holds at every block; an implementation SHOULD still check it and treat a violation as a consensus fault. Burning MUST NOT occur anywhere in the fee path. + +## Block transition + +The authoritative algorithm. Inputs: the pre-block state, the pre-block ledger balances, the block's transactions in block order, and the producer's account. + +Block processing MUST be transactional. All validation, reservations, executions, settlements, escrow changes, window and carry changes, producer credits, height changes, and base-fee updates apply to a working copy of the state. If any validity condition fails, the block is rejected and the pre-block state and balances MUST remain unchanged, byte for byte. The working state is committed only after every condition has succeeded. A transaction-level failure or revert discards that transaction's execution effects but retains its settled fee; a block-level rejection discards everything. A consensus fault (an executor reporting cycles above `gas_limit`, or a height increment at 2⁶⁴ - 1) is not a rejection: it halts the node instead of producing a state. + +**Execution state.** `balances` stands for the complete working consensus state: account balances and all execution-visible application state. Each transaction executes against a transaction-local checkpoint created after its fee reservation. On success its execution effects are retained; on revert or transaction-level failure they are discarded while its fee hold remains and is settled against consumed cycles. Successful execution effects and released reservations are visible to later transactions in the block. The producer's payout and tips are credited only after all transactions have settled, so neither is visible to any transaction in that block. + +```python +def block_transition(pre_state, pre_balances, txs, producer): + state, balances = copy(pre_state), copy(pre_balances) # working copies + + # 1. Static fee-validity and the storage cap (see Fee-validity). + validate_static_block(txs, state) # reject -> pre-state unchanged + if state.height == 2**64 - 1: + consensus_fault("block height overflow") + state.height += 1 + + # 2. Reserve, execute, settle. In block order. The execution cap is + # enforced as metered cycles become known. + revenue_base = revenue_tip = 0 + gas_used_exec = gas_used_stor = 0 + for tx in txs: + reserve = fee_reserve(tx, state) + if balances[tx.payer] < reserve: + reject("fee debit failed: payer cannot cover reserve") + balances[tx.payer] -= reserve # hold the reserve + if tx.kind is PUBLIC: + outcome = execute(tx) # metered, capped at gas_limit; tx-level + # failure discards its execution effects, + # not its fee + if outcome.cycles > tx.gas_limit: + consensus_fault("executor exceeded gas_limit") + cycles = outcome.cycles + else: + cycles = PRIVATE_VERIFY_GAS # proof validity is external + gas_used_exec += cycles + if gas_used_exec > MAX_GAS_EXEC: + reject("block exceeds MAX_GAS_EXEC") + gas_used_stor += gas_stor(tx) + f_base = cycles * state.base_fee_exec + gas_stor(tx) * state.base_fee_stor + balances[tx.payer] += reserve - (f_base + tx.tip) # release unused part + revenue_base += f_base + revenue_tip += tx.tip + + # 3. Distribute (see Revenue distribution). Producer credit comes last. + state.escrow += revenue_base + state.window.push_evicting_oldest(revenue_base) + numerator = sum(state.window) + state.payout_carry + payout = numerator // SMOOTHING_WINDOW + state.payout_carry = numerator % SMOOTHING_WINDOW + state.escrow -= payout + balances[producer] += payout + revenue_tip + + # 4. Base-fee updates for the next block (see Base-fee update). + state.base_fee_exec = next_base_fee(state.base_fee_exec, gas_used_exec, + TARGET_GAS_EXEC, D_EXEC, BASE_FEE_EXEC_MIN, BASE_FEE_EXEC_MAX) + state.base_fee_stor = next_base_fee(state.base_fee_stor, gas_used_stor, + TARGET_GAS_STOR, D_STOR, BASE_FEE_STOR_MIN, BASE_FEE_STOR_MAX) + + # 5. Commit. + return state, balances +``` + +This applies to every included transaction, including any originated by the producer: the producer pays base fees on its own transactions in full, into the escrow. The manipulation analysis in *Details* depends on this rule. + +## Parameters + +All values are protocol constants. Changing any of them is a protocol-version change (see *Versioning*). + +| Name | Value | Meaning | +| --- | --- | --- | +| `TARGET_GAS_EXEC` | 5,000,000 | Execution gas target per block | +| `MAX_GAS_EXEC` | 10,000,000 | Execution gas cap per block | +| `TARGET_GAS_STOR` | 500,000 | Storage bytes target per block | +| `MAX_GAS_STOR` | 1,000,000 | Storage bytes cap per block | +| `D_EXEC`, `D_STOR` | 8 | Adjustment denominators (max ±12.5%/block) | +| `BASE_FEE_EXEC_MIN` | 8 | Minimum execution base fee (atomic/gas) | +| `BASE_FEE_STOR_MIN` | 8 | Minimum storage base fee (atomic/byte) | +| `BASE_FEE_EXEC_MAX` | ⌊(2⁶⁴ - 1)/`MAX_GAS_EXEC`⌋ = 1,844,674,407,370 | Saturation cap | +| `BASE_FEE_STOR_MAX` | ⌊(2⁶⁴ - 1)/`MAX_GAS_STOR`⌋ = 18,446,744,073,709 | Saturation cap | +| `SMOOTHING_WINDOW` | 50 | Payout slots per unit of base revenue | +| `PRIVATE_VERIFY_GAS` | 409,764 | Execution gas of every private transaction | +| `PROOF_BYTES` | 223,551 | Proof bytes inside every private transaction | +| `PRIVATE_PAD_BYTES` | 512 | Payload size every private transaction is padded to | +| `PRIVATE_GAS_STOR` | 224,063 | Canonical serialized size of every private transaction | + + + +The minimums keep the integer controllers live (see *Details*); the caps make every per-resource fee product fit 64 bits. `PRIVATE_VERIFY_GAS` and `PROOF_BYTES` are measured values for STARK receipt verification (RISC Zero 3.0.5); the planned Groth16 upgrade changes both (see *Operating envelope*). + +## Genesis + +At genesis: `base_fee_exec` = `BASE_FEE_EXEC_MIN`, `base_fee_stor` = `BASE_FEE_STOR_MIN`, `escrow` = 0, `window` = 50 zero slots, `payout_carry` = 0, `height` = 0. Genesis validation MUST check `MAX_GAS_r` = 2·`TARGET_GAS_r` for both resources (the ±12.5% bound and the elasticity framing assume it). Prices start at the minimum and congestion alone moves them up. + +## Invariants + +A conformant implementation MUST preserve all of these after every committed block, and MUST treat a violation as a consensus fault: + +1. **Conservation (cumulative).** Over any chain prefix: Σ `revenue_base` + Σ tips = `escrow` + Σ payouts + Σ tips paid. Per block: payer debits net of released reservations equal `revenue_base` + tips, producer credits equal payout + tips, and Δ`escrow` = `revenue_base` − payout. +2. **Bounded adjustment.** Each base fee stays in `[lo, hi]` and moves by at most `max(1, b // d)` per block. +3. **Private indistinguishability.** All private transactions in a block have identical `fee_total`. +4. **Escrow, window, carry.** `escrow ≥ 0`, payout ≤ escrow, `len(window)` = `SMOOTHING_WINDOW`, and `payout_carry < SMOOTHING_WINDOW`. +5. **Caps.** Both fee-validity caps hold, and `MAX_GAS_r · base_fee_r` fits u64 for both resources. +6. **Producer pays.** Producer-originated transactions are reserved and settled like any other. +7. **Atomicity.** A rejected block leaves state and balances unchanged. + +# Details + +## Why the integer delta form + +A multiplicative update computed as a rounded product misbehaves at small prices: with floor rounding, prices below `d` can never rise but always fall, making zero absorbing; with round-half-to-even, prices at or below `d`/2 freeze in both directions. The delta form with a guaranteed +1 up-step rises from any price, and a minimum of `d` = 8 keeps proportional adjustment meaningful. At the top of the range, fee products (`gas × price`) overflow 64 bits roughly a hundred congested blocks *before* the price itself does, and every naive overflow semantics fails differently (wrapping forks consensus, checked halts the chain), so updates are computed widened and the price is clamped at `MAX_GAS`-aware caps under which every valid block's fee product fits u64. Full analysis and simulations: LEZ Fee Market Model, experiments E6, E10 to E12. At 10⁹ atomic units per LGO, the minimum of 8 prices a minimal transfer near 10⁻⁶ LGO, so the minimum has negligible cost. + +## Privacy + +The fee is the only protocol-visible quantity a private transaction chooses. Both of its gas amounts are protocol constants, its wire size is constant, and it carries no public fee fields, so its `fee_total` is a deterministic function of public state (the current base fees) alone. An observer learns nothing about the private computation, its complexity, or its payload size from fees or transaction size. The unpadded payload length is never exposed to the fee subsystem; the proof and wire-format validity rules enforce the bound. Bucketed padding (charging the smallest of a few fixed payload sizes, leaking ⌈log₂ buckets⌉ bits in exchange for cheaper small transactions) was considered and deferred; a single pad is the strongest choice and the simplest to implement. + +## Manipulation resistance + +Because base fees are retained rather than burned, a producer might try to stuff its own blocks with junk to inflate the base fee it later collects. Two mechanism features defeat the always-on version of this: the producer's junk pays the very base fee it inflates (invariant 6), and the smoothing window pays that revenue out over the next `SMOOTHING_WINDOW` blocks, mostly to other producers in the rotation, so only a small fraction of what the junk cost ever returns. Simulation of all four always-on strategies (execution/storage junk × rotation/cartel) found every one unprofitable (model document, E9). Open caveat: an adaptive stuff-then-harvest strategy is untested and plausibly profitable under fully inelastic demand; it is the named follow-up experiment, and the burn question reopens only if it succeeds. + +On payout fairness: with equal production slots and stationary revenue, long-run payouts equalize across the rotation, since every producer draws the same window average in expectation. Exact per-rotation equality is not guaranteed; it depends on the revenue path and on where each producer's slots fall relative to the window. + +## Operating envelope + +Under the current STARK proof (223,551 B), storage bounds private throughput: 4 private transactions per block, and simulation under inelastic demand found no interior price equilibrium once private transactions exceed roughly 10 to 11% of submitted transaction count. Above that share the storage base fee keeps rising until demand yields or the saturation cap is reached. This is an operating constraint, not a mechanism fault. The planned Groth16 upgrade shrinks proofs to about 500 B, which removes the constraint; it re-pins `PRIVATE_VERIFY_GAS`, `PROOF_BYTES`, and `PRIVATE_GAS_STOR` . + +# Annex + +## A. Reference implementation (Python) + +Informative executable model; the Protocol section governs on any disagreement. Integer-only, raises on invalid input, names match the specification one-to-one. `block_transition` is pure: it never mutates its inputs, so rejection cannot corrupt state. Execution is mocked through `mock_cycles`, test scaffolding that scripts the metering result; a real node obtains cycles from the execution outcome. + +```python +"""LEZ fee market: executable model (informative; the Protocol prose is normative). + +Integer-only. Every division is floor division on non-negative integers. +Names match the specification one-to-one. Raises on invalid input rather than +relying on `assert` (which `python -O` disables). + +`block_transition` is pure: it never mutates its inputs. On rejection it +raises and the caller's pre-state is untouched, as the Protocol requires. + +Execution is mocked: `mock_cycles` scripts the metering result the executor +would produce. It is test scaffolding, not wire data; a real node obtains +cycles from the execution outcome. +""" + +from dataclasses import dataclass, field +from collections import deque +from copy import deepcopy +from enum import Enum + +PROTOCOL_VERSION = 1 + +# --- Parameters (protocol constants) ----------------------------------------- + +TARGET_GAS_EXEC = 5_000_000 # execution gas target per block +MAX_GAS_EXEC = 10_000_000 # execution gas cap per block (= 2 * target) +TARGET_GAS_STOR = 500_000 # storage bytes target per block +MAX_GAS_STOR = 1_000_000 # storage bytes cap per block (= 2 * target) +D_EXEC = 8 # execution adjustment denominator +D_STOR = 8 # storage adjustment denominator +BASE_FEE_EXEC_MIN = 8 # atomic units per gas +BASE_FEE_STOR_MIN = 8 # atomic units per byte +BASE_FEE_EXEC_MAX = (2**64 - 1) // MAX_GAS_EXEC # 1_844_674_407_370 +BASE_FEE_STOR_MAX = (2**64 - 1) // MAX_GAS_STOR # 18_446_744_073_709 +SMOOTHING_WINDOW = 50 # payout slots per unit of base revenue +PRIVATE_VERIFY_GAS = 409_764 # execution gas of any private tx +PROOF_BYTES = 223_551 # proof bytes inside any private tx +PRIVATE_PAD_BYTES = 512 # TODO: confirm real payload size with LEZ +PRIVATE_GAS_STOR = PRIVATE_PAD_BYTES + PROOF_BYTES # 224_063; see note below +# PRIVATE_GAS_STOR is the canonical serialized size of every private +# transaction (envelope, proof, and padded payload). The wire format MUST make +# this size constant. The provisional value assumes zero envelope overhead; +# it is re-pinned together with PRIVATE_PAD_BYTES once the wire numbers exist. +# The unpadded payload bound is enforced by the proof and wire-format validity +# rules, not by the fee subsystem. + +TOTAL_SUPPLY = 10**19 # atomic units; bounds every real balance +U64_MAX = 2**64 - 1 + +if MAX_GAS_EXEC != 2 * TARGET_GAS_EXEC or MAX_GAS_STOR != 2 * TARGET_GAS_STOR: + raise ValueError("genesis validation: MAX_GAS_r must equal 2 * TARGET_GAS_r") + +class TxKind(Enum): + PUBLIC = "public" + PRIVATE = "private" + +class InvalidBlock(Exception): + """Block (or a transaction in it) violates a validity rule.""" + +class ConsensusFault(Exception): + """A guarantee the mechanism relies on was violated; halt.""" + +@dataclass(frozen=True) +class Transaction: + kind: TxKind + payer: int # AccountId; account debited for the fee + gas_limit: int = 0 # u64; public: declared execution bound + data_bytes: int = 0 # u64, derived; public: canonical serialized length + tip: int = 0 # u64; atomic units; 0 for private + max_fee: int = 0 # u128; public: signed cap on the fee reserve; 0 for private + mock_cycles: int = 0 # TEST SCAFFOLD ONLY: scripted metering result. + # Not a wire field; a real node gets cycles from + # the execution outcome. + +@dataclass(frozen=True) +class ExecutionOutcome: + cycles: int # metered cycles; the executor halts at gas_limit + +def execute_public(tx: Transaction) -> ExecutionOutcome: + """Mock executor. A real node runs the zkVM here, metering capped at + gas_limit, applying execution effects to a transaction-local checkpoint.""" + return ExecutionOutcome(cycles=tx.mock_cycles) + +@dataclass +class State: + base_fee_exec: int = BASE_FEE_EXEC_MIN # u64; genesis: the minimum + base_fee_stor: int = BASE_FEE_STOR_MIN # u64; genesis: the minimum + escrow: int = 0 # u128 + window: deque = field(default_factory=lambda: deque( + [0] * SMOOTHING_WINDOW, maxlen=SMOOTHING_WINDOW)) # [u128; 50], zeros + payout_carry: int = 0 # in [0, SMOOTHING_WINDOW - 1] + height: int = 0 # u64; checked increment + +``` + +```python + +# --- Fee assessment ----------------------------------------------------------- + +def gas_stor(tx: Transaction) -> int: + """Storage gas, known before execution.""" + if tx.kind is TxKind.PRIVATE: + return PRIVATE_GAS_STOR + return tx.data_bytes + +def fee_reserve(tx: Transaction, state: State) -> int: + """Amount held from the payer before execution. Only the execution part is + uncertain in advance, so the reserve prices gas_limit instead of cycles.""" + if tx.kind is TxKind.PRIVATE: + return (PRIVATE_VERIFY_GAS * state.base_fee_exec + + PRIVATE_GAS_STOR * state.base_fee_stor) + return (tx.gas_limit * state.base_fee_exec + + tx.data_bytes * state.base_fee_stor + tx.tip) + +def fee_actual_base(cycles: int, tx: Transaction, state: State) -> int: + """Actual base fee, known once cycles are metered.""" + return cycles * state.base_fee_exec + gas_stor(tx) * state.base_fee_stor + +``` + +```python + +# --- Static fee-validity (everything known before execution) ------------------- + +def validate_static_tx(tx: Transaction, state: State) -> None: + if not isinstance(tx.kind, TxKind): + raise InvalidBlock("unknown transaction kind") + for name in ("payer", "gas_limit", "data_bytes", "tip", "max_fee"): + v = getattr(tx, name) + if type(v) is not int or v < 0: # bool is not accepted as int + raise InvalidBlock(f"{name} must be a non-negative integer") + if tx.tip > U64_MAX or tx.max_fee > 2**128 - 1: + raise InvalidBlock("field exceeds its type width") + if tx.kind is TxKind.PRIVATE: + if tx.tip != 0 or tx.max_fee != 0 or tx.gas_limit != 0 or tx.data_bytes != 0: + raise InvalidBlock("private tx carries a public-only field") + else: + if tx.data_bytes < 1: + raise InvalidBlock("public tx serialization is empty") + if tx.data_bytes > MAX_GAS_STOR: + raise InvalidBlock("public tx exceeds MAX_GAS_STOR") + if tx.gas_limit > MAX_GAS_EXEC: + raise InvalidBlock("gas_limit exceeds MAX_GAS_EXEC") + if fee_reserve(tx, state) > tx.max_fee: + raise InvalidBlock("fee_reserve exceeds signed max_fee") + +def validate_static_block(txs: list, state: State) -> None: + """Static rules plus the storage cap; serialized lengths are known before + execution. The execution cap is enforced dynamically in the transition.""" + total_stor = 0 # unbounded int; compare, never wrap + for tx in txs: + validate_static_tx(tx, state) + total_stor += gas_stor(tx) + if total_stor > MAX_GAS_STOR: + raise InvalidBlock("block exceeds MAX_GAS_STOR") +``` + +```python + +# --- Base-fee update ---------------------------------------------------------- + +def next_base_fee(b: int, gas_used: int, target: int, d: int, lo: int, hi: int) -> int: + if gas_used > target: + deviation = min(gas_used - target, target) + delta = max(1, (b * deviation) // (target * d)) + return min(hi, b + delta) + if gas_used < target: + deviation = min(target - gas_used, target) + delta = (b * deviation) // (target * d) + return max(lo, b - delta) + return b + +# --- Block transition --------------------------------------------------------- + +@dataclass(frozen=True) +class BlockReceipt: + height: int + revenue_base: int + revenue_tip: int + payout: int + +def block_transition(pre_state: State, pre_balances: dict, txs: list, + producer: int): + """The normative transition. Returns (state, balances, receipt) on success; + raises InvalidBlock on rejection, leaving pre_state and pre_balances + untouched (they are never mutated).""" + state = deepcopy(pre_state) # working state + balances = dict(pre_balances) # working balances + + # 1. Static fee-validity and the storage cap (see Fee-validity). + validate_static_block(txs, state) + if state.height == U64_MAX: + raise ConsensusFault("block height overflow") + state.height += 1 + + # 2. Reserve, execute, settle. In block order. The execution cap is + # enforced as cycles become known. + revenue_base = revenue_tip = 0 + gas_used_exec = gas_used_stor = 0 + for tx in txs: + reserve = fee_reserve(tx, state) + if balances.get(tx.payer, 0) < reserve: + raise InvalidBlock("fee debit failed: payer cannot cover reserve") + balances[tx.payer] -= reserve # hold the reserve + if tx.kind is TxKind.PUBLIC: + # Execution happens against a transaction-local checkpoint; a + # transaction-level failure discards its execution effects, not + # its fee. The model applies no execution effects. + outcome = execute_public(tx) + if outcome.cycles > tx.gas_limit: + raise ConsensusFault("executor exceeded gas_limit") + cycles = outcome.cycles + else: + # Proof validity is external; an invalid proof is an invalid + # transaction and never reaches this point. + cycles = PRIVATE_VERIFY_GAS + gas_used_exec += cycles + if gas_used_exec > MAX_GAS_EXEC: + raise InvalidBlock("block exceeds MAX_GAS_EXEC") + gas_used_stor += gas_stor(tx) + f_base = fee_actual_base(cycles, tx, state) + balances[tx.payer] += reserve - (f_base + tx.tip) # release unused part + revenue_base += f_base + revenue_tip += tx.tip + + # 3. Distribute: base fees through escrow; tips direct. The producer is + # credited only here, after every transaction has settled, so neither + # tips nor the payout of this block can fund a transaction in it. + state.escrow += revenue_base + state.window.append(revenue_base) # evicts the oldest of the 50 slots + numerator = sum(state.window) + state.payout_carry + payout = numerator // SMOOTHING_WINDOW + state.payout_carry = numerator % SMOOTHING_WINDOW + if payout > state.escrow: + raise ConsensusFault("payout exceeds escrow") # unreachable by construction + state.escrow -= payout + balances[producer] = balances.get(producer, 0) + payout + revenue_tip + + # 4. Update base fees for the next block. + state.base_fee_exec = next_base_fee( + state.base_fee_exec, gas_used_exec, TARGET_GAS_EXEC, D_EXEC, + BASE_FEE_EXEC_MIN, BASE_FEE_EXEC_MAX) + state.base_fee_stor = next_base_fee( + state.base_fee_stor, gas_used_stor, TARGET_GAS_STOR, D_STOR, + BASE_FEE_STOR_MIN, BASE_FEE_STOR_MAX) + + # 5. Commit: the caller adopts the returned state and balances. + return state, balances, BlockReceipt(state.height, revenue_base, + revenue_tip, payout) +``` + +**Usage.** One block with one public and one private transaction, from genesis: + +```python +from reference import * + +state = State() +balances = {1: 10**12, 2: 0} # payer, producer + +txs = [ + Transaction(TxKind.PUBLIC, payer=1, gas_limit=60_000, data_bytes=200, + tip=100, max_fee=10**9, mock_cycles=50_000), + Transaction(TxKind.PRIVATE, payer=1), +] + +state, balances, r = block_transition(state, balances, txs, producer=2) +print(r) # BlockReceipt(height=1, revenue_base=5472216, revenue_tip=100, payout=109444) +print(balances) # {1: 999994527684, 2: 109544} +``` + +## B. Exemplary Rust implementation + +Informative. Base fees and gas are `u64`; fee totals, balances, and every intermediate product are `u128`. `block_transition` takes the pre-state by reference and returns a new state, so a rejected block leaves the caller's state untouched. Checked (debug) and unchecked (release) builds produce identical output. The file's `main` is the cross-check driver whose output matches the Python model byte for byte. + +```rust +//! LEZ fee market: exemplary Rust implementation (informative; prose is normative). +//! +//! Base fees and gas are `u64`; fee totals, balances, and every intermediate +//! product are `u128`, so no valid input can wrap. Static validity checks +//! reject before any arithmetic can exceed its type; the execution cap is +//! enforced dynamically as metered cycles become known. `block_transition` is +//! pure: it takes the pre-state by reference and returns a new state, so a +//! rejected block leaves the caller's state untouched. Checked (debug) and +//! unchecked (release) builds produce identical output. +//! +//! Execution is mocked: `mock_cycles` scripts the metering result. It is test +//! scaffolding, not wire data; a real node obtains cycles from the execution +//! outcome. +//! +//! Build & run the cross-check: `rustc -O reference.rs && ./reference` +//! (output must be byte-identical to `python3 harness.py crosscheck`). + +use std::collections::{HashMap, VecDeque}; + +pub const PROTOCOL_VERSION: u32 = 1; + +// --- Parameters (protocol constants) ----------------------------------------- + +pub const TARGET_GAS_EXEC: u64 = 5_000_000; +pub const MAX_GAS_EXEC: u64 = 10_000_000; // = 2 * target (checked at genesis) +pub const TARGET_GAS_STOR: u64 = 500_000; +pub const MAX_GAS_STOR: u64 = 1_000_000; // = 2 * target (checked at genesis) +pub const D_EXEC: u64 = 8; +pub const D_STOR: u64 = 8; +pub const BASE_FEE_EXEC_MIN: u64 = 8; +pub const BASE_FEE_STOR_MIN: u64 = 8; +pub const BASE_FEE_EXEC_MAX: u64 = u64::MAX / MAX_GAS_EXEC; // 1_844_674_407_370 +pub const BASE_FEE_STOR_MAX: u64 = u64::MAX / MAX_GAS_STOR; // 18_446_744_073_709 +pub const SMOOTHING_WINDOW: usize = 50; +pub const PRIVATE_VERIFY_GAS: u64 = 409_764; +pub const PROOF_BYTES: u64 = 223_551; +pub const PRIVATE_PAD_BYTES: u64 = 512; // TODO: confirm real payload size with LEZ +/// Canonical serialized size of every private transaction (envelope, proof, +/// padded payload). The wire format MUST make this size constant. The +/// provisional value assumes zero envelope overhead. The unpadded payload +/// bound is enforced by the proof and wire-format validity rules, not here. +pub const PRIVATE_GAS_STOR: u64 = PRIVATE_PAD_BYTES + PROOF_BYTES; // 224_063 + +pub const TOTAL_SUPPLY: u128 = 10_000_000_000_000_000_000; // 10^19 atomic units + +pub fn validate_genesis_params() { + assert!(MAX_GAS_EXEC == 2 * TARGET_GAS_EXEC && MAX_GAS_STOR == 2 * TARGET_GAS_STOR); +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum TxKind { + Public, + Private, +} + +/// Field types are unsigned, so non-negativity holds by construction. +#[derive(Clone, Copy)] +pub struct Transaction { + pub kind: TxKind, + pub payer: u64, // AccountId; account debited for the fee + pub gas_limit: u64, // public: declared execution bound + pub data_bytes: u64, // derived; public: canonical serialized length + pub tip: u64, // atomic units; 0 for private + pub max_fee: u128, // public: signed cap on the fee reserve; 0 for private + pub mock_cycles: u64, // TEST SCAFFOLD ONLY: scripted metering result. +} + +pub struct ExecutionOutcome { + pub cycles: u64, // metered cycles; the executor halts at gas_limit +} + +/// Mock executor. A real node runs the zkVM here, metering capped at +/// gas_limit, applying execution effects to a transaction-local checkpoint. +pub fn execute_public(tx: &Transaction) -> ExecutionOutcome { + ExecutionOutcome { cycles: tx.mock_cycles } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct State { + pub base_fee_exec: u64, + pub base_fee_stor: u64, + pub escrow: u128, + pub window: VecDeque, // exactly SMOOTHING_WINDOW slots, zero-initialized + pub payout_carry: u128, // in [0, SMOOTHING_WINDOW - 1] + pub height: u64, +} + +impl State { + pub fn genesis() -> Self { + validate_genesis_params(); + State { + base_fee_exec: BASE_FEE_EXEC_MIN, // genesis: the minimum + base_fee_stor: BASE_FEE_STOR_MIN, // genesis: the minimum + escrow: 0, + window: VecDeque::from(vec![0u128; SMOOTHING_WINDOW]), + payout_carry: 0, + height: 0, + } + } +} + +// --- Fee assessment ----------------------------------------------------------- + +/// Storage gas, known before execution. +pub fn gas_stor(tx: &Transaction) -> u64 { + match tx.kind { + TxKind::Private => PRIVATE_GAS_STOR, + TxKind::Public => tx.data_bytes, + } +} + +/// Amount held from the payer before execution. Only the execution part is +/// uncertain in advance, so the reserve prices gas_limit instead of cycles. +pub fn fee_reserve(tx: &Transaction, state: &State) -> u128 { + match tx.kind { + TxKind::Private => { + PRIVATE_VERIFY_GAS as u128 * state.base_fee_exec as u128 + + PRIVATE_GAS_STOR as u128 * state.base_fee_stor as u128 + } + TxKind::Public => { + tx.gas_limit as u128 * state.base_fee_exec as u128 + + tx.data_bytes as u128 * state.base_fee_stor as u128 + + tx.tip as u128 + } + } +} + +/// Actual base fee, known once cycles are metered. +pub fn fee_actual_base(cycles: u64, tx: &Transaction, state: &State) -> u128 { + cycles as u128 * state.base_fee_exec as u128 + + gas_stor(tx) as u128 * state.base_fee_stor as u128 +} + +// --- Static fee-validity (everything known before execution) ------------------- + +pub fn validate_static_tx(tx: &Transaction, state: &State) -> Result<(), &'static str> { + match tx.kind { + TxKind::Private => { + if tx.tip != 0 || tx.max_fee != 0 || tx.gas_limit != 0 || tx.data_bytes != 0 { + return Err("private tx carries a public-only field"); + } + } + TxKind::Public => { + if tx.data_bytes < 1 { + return Err("public tx serialization is empty"); + } + if tx.data_bytes > MAX_GAS_STOR { + return Err("public tx exceeds MAX_GAS_STOR"); + } + if tx.gas_limit > MAX_GAS_EXEC { + return Err("gas_limit exceeds MAX_GAS_EXEC"); + } + if fee_reserve(tx, state) > tx.max_fee { + return Err("fee_reserve exceeds signed max_fee"); + } + } + } + Ok(()) +} + +/// Static rules plus the storage cap; serialized lengths are known before +/// execution. The execution cap is enforced dynamically in the transition. +pub fn validate_static_block(txs: &[Transaction], state: &State) -> Result<(), &'static str> { + let mut total_stor = 0u128; // widened; never wraps + for tx in txs { + validate_static_tx(tx, state)?; + total_stor += gas_stor(tx) as u128; + if total_stor > MAX_GAS_STOR as u128 { + return Err("block exceeds MAX_GAS_STOR"); + } + } + Ok(()) +} + +// --- Base-fee update ---------------------------------------------------------- + +pub fn next_base_fee(b: u64, gas_used: u64, target: u64, d: u64, lo: u64, hi: u64) -> u64 { + if gas_used > target { + let deviation = (gas_used - target).min(target); + let delta = (b as u128 * deviation as u128) / (target as u128 * d as u128); + let delta = (delta as u64).max(1); + hi.min(b.saturating_add(delta)) + } else if gas_used < target { + let deviation = (target - gas_used).min(target); + let delta = (b as u128 * deviation as u128) / (target as u128 * d as u128); + lo.max(b - delta as u64) // delta <= b / d, so no underflow + } else { + b + } +} + +``` + +```rust +// --- Block transition --------------------------------------------------------- + +pub struct BlockReceipt { + pub height: u64, + pub revenue_base: u128, + pub revenue_tip: u128, + pub payout: u128, +} + +/// The normative transition. Returns the post-state on success; on rejection +/// returns Err and the caller's pre-state is untouched. Panics only on +/// consensus faults (executor exceeding gas_limit, height overflow). +pub fn block_transition( + pre_state: &State, + pre_balances: &HashMap, + txs: &[Transaction], + producer: u64, +) -> Result<(State, HashMap, BlockReceipt), &'static str> { + let mut state = pre_state.clone(); // working state + let mut balances = pre_balances.clone(); // working balances + + // 1. Static fee-validity and the storage cap (see Fee-validity). + validate_static_block(txs, &state)?; + state.height = state.height.checked_add(1) + .expect("consensus fault: block height overflow"); + + // 2. Reserve, execute, settle. In block order. The execution cap is + // enforced as cycles become known. + let (mut revenue_base, mut revenue_tip) = (0u128, 0u128); + let (mut gas_used_exec, mut gas_used_stor) = (0u64, 0u64); + for tx in txs { + let reserve = fee_reserve(tx, &state); + let bal = balances.entry(tx.payer).or_insert(0); + if *bal < reserve { + return Err("fee debit failed: payer cannot cover reserve"); + } + *bal -= reserve; // hold the reserve + let cycles = match tx.kind { + TxKind::Public => { + // Execution happens against a transaction-local checkpoint; a + // transaction-level failure discards its execution effects, + // not its fee. The model applies no execution effects. + let outcome = execute_public(tx); + assert!(outcome.cycles <= tx.gas_limit, + "consensus fault: executor exceeded gas_limit"); + outcome.cycles + } + // Proof validity is external; an invalid proof is an invalid + // transaction and never reaches this point. + TxKind::Private => PRIVATE_VERIFY_GAS, + }; + gas_used_exec += cycles; + if gas_used_exec > MAX_GAS_EXEC { + return Err("block exceeds MAX_GAS_EXEC"); + } + gas_used_stor += gas_stor(tx); + let f_base = fee_actual_base(cycles, tx, &state); + *balances.get_mut(&tx.payer).unwrap() += reserve - (f_base + tx.tip as u128); + revenue_base += f_base; + revenue_tip += tx.tip as u128; + } + + // 3. Distribute: base fees through escrow; tips direct. The producer is + // credited only here, after every transaction has settled, so neither + // tips nor the payout of this block can fund a transaction in it. + state.escrow += revenue_base; + state.window.pop_front(); + state.window.push_back(revenue_base); // window keeps exactly 50 slots + let numerator = state.window.iter().sum::() + state.payout_carry; + let payout = numerator / SMOOTHING_WINDOW as u128; + state.payout_carry = numerator % SMOOTHING_WINDOW as u128; + assert!(payout <= state.escrow, "consensus fault: payout exceeds escrow"); + state.escrow -= payout; + *balances.entry(producer).or_insert(0) += payout + revenue_tip; + + // 4. Update base fees for the next block. + state.base_fee_exec = next_base_fee( + state.base_fee_exec, gas_used_exec, TARGET_GAS_EXEC, D_EXEC, + BASE_FEE_EXEC_MIN, BASE_FEE_EXEC_MAX); + state.base_fee_stor = next_base_fee( + state.base_fee_stor, gas_used_stor, TARGET_GAS_STOR, D_STOR, + BASE_FEE_STOR_MIN, BASE_FEE_STOR_MAX); + + // 5. Commit: the caller adopts the returned state and balances. + let height = state.height; + Ok((state, balances, BlockReceipt { height, revenue_base, revenue_tip, payout })) +} + +// --- Cross-language check (identical in harness.py) --------------------------- + +struct Lcg(u64); + +impl Lcg { + fn below(&mut self, n: u64) -> u64 { + self.0 = self.0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (self.0 >> 33) % n + } +} + +const PAYER: u64 = 1; +const PRODUCER: u64 = 2; + +fn tx_exec_gas(tx: &Transaction) -> u64 { + match tx.kind { + TxKind::Private => PRIVATE_VERIFY_GAS, + TxKind::Public => tx.mock_cycles, + } +} + +fn pub_tx(cycles: u64, data: u64, tip: u64, payer: u64) -> Transaction { + Transaction { + kind: TxKind::Public, payer, gas_limit: cycles, + data_bytes: data, tip, max_fee: 10u128.pow(27), mock_cycles: cycles, + } +} + +fn prv_tx() -> Transaction { + Transaction { + kind: TxKind::Private, payer: PAYER, gas_limit: 0, + data_bytes: 0, tip: 0, max_fee: 0, mock_cycles: 0, + } +} + +fn main() { + let mut state = State::genesis(); + let mut balances: HashMap = HashMap::new(); + balances.insert(PAYER, 10u128.pow(30)); + balances.insert(PRODUCER, 10u128.pow(30)); + + let scenario: Vec> = vec![ + vec![], + vec![pub_tx(1_000_000, 40_000, 500, PAYER)], + vec![pub_tx(5_000_000, 100_000, 0, PAYER)], + vec![pub_tx(9_000_000, 900_000, 2_000, PAYER)], + vec![pub_tx(10_000_000, 1_000_000, 0, PAYER)], + vec![prv_tx(), prv_tx(), prv_tx(), prv_tx()], + vec![pub_tx(2_500_000, 10_000, 1_000, PAYER), prv_tx(), prv_tx()], + vec![], + ]; + for txs in &scenario { + let (s, b, r) = block_transition(&state, &balances, txs, PRODUCER).unwrap(); + state = s; + balances = b; + println!( + "{} {} {} {} {} {} {} {}", + r.height, state.base_fee_exec, state.base_fee_stor, + r.revenue_base, r.revenue_tip, r.payout, + state.payout_carry, state.escrow + ); + } + + let mut rng = Lcg(42); + for _ in 0..10_000 { + let mut txs: Vec = Vec::new(); + for _ in 0..rng.below(4) { + txs.push(prv_tx()); + } + let mut exec_left = MAX_GAS_EXEC - txs.iter().map(tx_exec_gas).sum::(); + let mut stor_left = MAX_GAS_STOR - txs.iter().map(gas_stor).sum::(); + for _ in 0..rng.below(12) { + let c = 40_000 + rng.below(610_000); + let d = 1 + rng.below(5_000); + let t = rng.below(10_000); + let payer = if rng.below(4) == 0 { PRODUCER } else { PAYER }; + if c <= exec_left && d <= stor_left { + txs.push(pub_tx(c, d, t, payer)); + exec_left -= c; + stor_left -= d; + } + } + let (s, b, _r) = block_transition(&state, &balances, &txs, PRODUCER).unwrap(); + state = s; + balances = b; + } + println!( + "final {} {} {} {} {} {} {}", + state.base_fee_exec, state.base_fee_stor, state.escrow, + state.payout_carry, state.window.iter().sum::(), + balances[&PAYER], balances[&PRODUCER] + ); +} +``` + +## C. Parameter provenance + +`PRIVATE_VERIFY_GAS` = 409,764: verification time 12.215 ms × calibrated 33,546 cycles/ms (95% CI 403k to 418k), RISC Zero 3.0.5 STARK receipt, CPU-only, single pinned machine. `PROOF_BYTES` = 223,551: Borsh-serialized `InnerReceipt`, constant across measured programs. Public cycle counts for context: measured programs span 43,818 to 643,464 cycles. Storage caps 500 kB/1 MB reflect the current block size of about 1 MB. Re-derivation: rerun `cycle_bench` in vacp2p/token-economics on the pinned benchmark machine and apply *Versioning*. + +## E. References + +- Buterin et al. (2019). *EIP-1559: Fee market change for ETH 1.0 chain.* The base-fee mechanism this specification adapts. https://eips.ethereum.org/EIPS/eip-1559 +- Angeris, Diamandis, Chitra (2024). *Multidimensional Blockchain Fees are (Essentially) Optimal.* Why independent per-resource controllers are the right structure. https://arxiv.org/abs/2402.08661 +- Basu, Easley, O'Hara, Sirer (2023). *StableFees: A Predictable Fee Market for Cryptocurrencies.* Management Science 69(11). Origin of the payout smoothing idea. https://doi.org/10.1287/mnsc.2023.4735 +- Ethereum Foundation. *EIP-4844: Shard Blob Transactions.* Deployed precedent for a second, independent resource market. https://eips.ethereum.org/EIPS/eip-4844 +- *LEZ Fee Market Model.* Design analysis and experiments E1 to E12 behind every parameter and rule here; simulation code at vacp2p/token-economics. LEZ Fee Market Model \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index fde6b7817..3799c8dda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1462,6 +1462,7 @@ dependencies = [ "anyhow", "borsh", "common", + "fee_core", "futures", "lee", "lee_core", @@ -1679,6 +1680,7 @@ dependencies = [ "base64 0.22.1", "borsh", "clock_core", + "fee_core", "hex", "lee", "lee_core", @@ -3012,6 +3014,22 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fee_core" +version = "0.1.0" +dependencies = [ + "lee_core", + "serde", +] + +[[package]] +name = "fee_program" +version = "0.1.0" +dependencies = [ + "fee_core", + "lee_core", +] + [[package]] name = "ferroid" version = "2.0.0" @@ -8076,6 +8094,7 @@ dependencies = [ "cross_zone_inbox_core", "cross_zone_outbox_core", "faucet_core", + "fee_core", "lee", "lee_core", "ping_core", @@ -9539,6 +9558,7 @@ dependencies = [ "cross_zone", "cross_zone_inbox_core", "faucet_core", + "fee_core", "futures", "hex", "humantime-serde", @@ -10230,6 +10250,7 @@ version = "0.1.0" dependencies = [ "borsh", "common", + "fee_core", "lee", "log", "programs", @@ -10416,6 +10437,7 @@ dependencies = [ "bridge_core", "clock_core", "faucet_core", + "fee_core", "lee_core", "programs", ] diff --git a/Cargo.toml b/Cargo.toml index ce2a6e5e5..5a842f342 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ members = [ "lez/programs/bridge", "lez/programs/clock", "lez/programs/faucet", + "lez/programs/fee", "lez/programs/pinata", "lez/programs/pinata_token", "lez/programs/token", @@ -103,6 +104,7 @@ programs = { path = "lez/programs", default-features = false, features = [ ] } system_accounts = { path = "lez/system_accounts" } clock_core = { path = "lez/programs/clock/core" } +fee_core = { path = "lez/programs/fee/core" } token_core = { path = "lez/programs/token/core" } token_program = { path = "lez/programs/token" } amm_core = { path = "lez/programs/amm/core" } diff --git a/artifacts/lez/programs/amm.bin b/artifacts/lez/programs/amm.bin index 2fdf142e7..088b1f282 100644 Binary files a/artifacts/lez/programs/amm.bin and b/artifacts/lez/programs/amm.bin differ diff --git a/artifacts/lez/programs/associated_token_account.bin b/artifacts/lez/programs/associated_token_account.bin index 60edcbbdc..754a73fb4 100644 Binary files a/artifacts/lez/programs/associated_token_account.bin and b/artifacts/lez/programs/associated_token_account.bin differ diff --git a/artifacts/lez/programs/authenticated_transfer.bin b/artifacts/lez/programs/authenticated_transfer.bin index 216282765..ae572fcb0 100644 Binary files a/artifacts/lez/programs/authenticated_transfer.bin and b/artifacts/lez/programs/authenticated_transfer.bin differ diff --git a/artifacts/lez/programs/bridge.bin b/artifacts/lez/programs/bridge.bin index c1b8d9884..b0dc4ff6c 100644 Binary files a/artifacts/lez/programs/bridge.bin and b/artifacts/lez/programs/bridge.bin differ diff --git a/artifacts/lez/programs/bridge_lock.bin b/artifacts/lez/programs/bridge_lock.bin index ddadd4ed5..b12252226 100644 Binary files a/artifacts/lez/programs/bridge_lock.bin and b/artifacts/lez/programs/bridge_lock.bin differ diff --git a/artifacts/lez/programs/clock.bin b/artifacts/lez/programs/clock.bin index c47419be3..cedf2f819 100644 Binary files a/artifacts/lez/programs/clock.bin and b/artifacts/lez/programs/clock.bin differ diff --git a/artifacts/lez/programs/cross_zone_inbox.bin b/artifacts/lez/programs/cross_zone_inbox.bin index ec1da83fa..9eca4e316 100644 Binary files a/artifacts/lez/programs/cross_zone_inbox.bin and b/artifacts/lez/programs/cross_zone_inbox.bin differ diff --git a/artifacts/lez/programs/cross_zone_outbox.bin b/artifacts/lez/programs/cross_zone_outbox.bin index ba3649f6b..ef9e18b55 100644 Binary files a/artifacts/lez/programs/cross_zone_outbox.bin and b/artifacts/lez/programs/cross_zone_outbox.bin differ diff --git a/artifacts/lez/programs/faucet.bin b/artifacts/lez/programs/faucet.bin index 399826ed6..24236e925 100644 Binary files a/artifacts/lez/programs/faucet.bin and b/artifacts/lez/programs/faucet.bin differ diff --git a/artifacts/lez/programs/fee.bin b/artifacts/lez/programs/fee.bin new file mode 100644 index 000000000..0b82a2847 Binary files /dev/null and b/artifacts/lez/programs/fee.bin differ diff --git a/artifacts/lez/programs/pinata.bin b/artifacts/lez/programs/pinata.bin index febc6cd86..54b39e5da 100644 Binary files a/artifacts/lez/programs/pinata.bin and b/artifacts/lez/programs/pinata.bin differ diff --git a/artifacts/lez/programs/pinata_token.bin b/artifacts/lez/programs/pinata_token.bin index d7a6844eb..45afc1be7 100644 Binary files a/artifacts/lez/programs/pinata_token.bin and b/artifacts/lez/programs/pinata_token.bin differ diff --git a/artifacts/lez/programs/ping_receiver.bin b/artifacts/lez/programs/ping_receiver.bin index dc2c06472..710eeeb8a 100644 Binary files a/artifacts/lez/programs/ping_receiver.bin and b/artifacts/lez/programs/ping_receiver.bin differ diff --git a/artifacts/lez/programs/ping_sender.bin b/artifacts/lez/programs/ping_sender.bin index 0ae3175bd..3a3e40c90 100644 Binary files a/artifacts/lez/programs/ping_sender.bin and b/artifacts/lez/programs/ping_sender.bin differ diff --git a/artifacts/lez/programs/token.bin b/artifacts/lez/programs/token.bin index d91927d3e..47bc6ef1f 100644 Binary files a/artifacts/lez/programs/token.bin and b/artifacts/lez/programs/token.bin differ diff --git a/artifacts/lez/programs/vault.bin b/artifacts/lez/programs/vault.bin index 5ce29680c..4a7b2a11e 100644 Binary files a/artifacts/lez/programs/vault.bin and b/artifacts/lez/programs/vault.bin differ diff --git a/artifacts/lez/programs/wrapped_token.bin b/artifacts/lez/programs/wrapped_token.bin index 6633ec468..4b19212a0 100644 Binary files a/artifacts/lez/programs/wrapped_token.bin and b/artifacts/lez/programs/wrapped_token.bin differ diff --git a/lez/chain_state/Cargo.toml b/lez/chain_state/Cargo.toml index 0a0610a51..ec7c6a45f 100644 --- a/lez/chain_state/Cargo.toml +++ b/lez/chain_state/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] common.workspace = true +fee_core.workspace = true lee.workspace = true lee_core.workspace = true logos-blockchain-core.workspace = true diff --git a/lez/chain_state/src/apply.rs b/lez/chain_state/src/apply.rs index 05134b88e..e66edf278 100644 --- a/lez/chain_state/src/apply.rs +++ b/lez/chain_state/src/apply.rs @@ -5,7 +5,7 @@ use common::{ HashType, block::{Block, BlockMeta}, - transaction::{LeeTransaction, clock_invocation}, + transaction::{LeeTransaction, clock_invocation, fee_invocation}, }; use lee::{GENESIS_BLOCK_ID, V03State}; @@ -123,7 +123,7 @@ pub fn validate_against_tip(tip: Option<&Tip>, block: &Block) -> Result<(), Bloc /// [`BlockIngestError`] so the caller can park rather than crash. Operates in /// place; the caller commits only on `Ok`. pub fn apply_block_to_state(block: &Block, state: &mut V03State) -> Result<(), BlockIngestError> { - let (clock_tx, user_txs) = block + let (clock_tx, front) = block .body .transactions .split_last() @@ -136,6 +136,16 @@ pub fn apply_block_to_state(block: &Block, state: &mut V03State) -> Result<(), B return Err(BlockIngestError::InvalidClockTransaction); } + let (fee_tx, user_txs) = front + .split_last() + .ok_or(BlockIngestError::InvalidFeeTransaction)?; + let LeeTransaction::Public(fee_tx) = fee_tx else { + return Err(BlockIngestError::InvalidFeeTransaction); + }; + if *fee_tx != fee_invocation(fee_core::Instruction::default()) { + return Err(BlockIngestError::InvalidFeeTransaction); + } + let is_genesis = block.header.block_id == GENESIS_BLOCK_ID; for (tx_index, transaction) in user_txs.iter().enumerate() { let state_transition = |err: anyhow::Error| BlockIngestError::StateTransition { @@ -162,12 +172,21 @@ pub fn apply_block_to_state(block: &Block, state: &mut V03State) -> Result<(), B } state - .transition_from_public_transaction(clock_tx, block.header.block_id, block.header.timestamp) + .transition_from_public_transaction(fee_tx, block.header.block_id, block.header.timestamp) .map_err(|err| BlockIngestError::StateTransition { tx_index: user_txs.len().try_into().expect("tx index fits in u64"), reason: format!("{:#}", anyhow::Error::from(err)), })?; + state + .transition_from_public_transaction(clock_tx, block.header.block_id, block.header.timestamp) + .map_err(|err| BlockIngestError::StateTransition { + tx_index: (user_txs.len().saturating_add(1)) + .try_into() + .expect("tx index fits in u64"), + reason: format!("{:#}", anyhow::Error::from(err)), + })?; + Ok(()) } @@ -266,6 +285,45 @@ mod tests { assert!(matches!(err, BlockIngestError::EmptyBlock)); } + #[test] + fn missing_fee_tx_is_invalid_fee() { + let mut state = initial_state(); + // Correct clock tail but no fee tx before it. + let block = HashableBlockData { + block_id: 1, + prev_block_hash: HashType([0_u8; 32]), + timestamp: 100, + transactions: vec![ + produce_dummy_empty_transaction(), + LeeTransaction::Public(clock_invocation(100)), + ], + } + .into_pending_block(&sequencer_sign_key_for_testing()); + let err = apply_block(None, &block, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::InvalidFeeTransaction)); + } + + #[test] + fn nonzero_fee_summary_is_invalid_fee() { + let mut state = initial_state(); + let bad_summary = fee_core::BlockFeeSummary { + gas_used_exec: 1, + ..fee_core::BlockFeeSummary::default() + }; + let block = HashableBlockData { + block_id: 1, + prev_block_hash: HashType([0_u8; 32]), + timestamp: 100, + transactions: vec![ + LeeTransaction::Public(fee_invocation(bad_summary)), + LeeTransaction::Public(clock_invocation(100)), + ], + } + .into_pending_block(&sequencer_sign_key_for_testing()); + let err = apply_block(None, &block, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::InvalidFeeTransaction)); + } + #[test] fn missing_clock_tail_is_invalid_clock() { let mut state = initial_state(); diff --git a/lez/chain_state/src/ingest_error.rs b/lez/chain_state/src/ingest_error.rs index d259b5f79..8e963ccec 100644 --- a/lez/chain_state/src/ingest_error.rs +++ b/lez/chain_state/src/ingest_error.rs @@ -26,6 +26,8 @@ pub enum BlockIngestError { EmptyBlock, #[error("Last transaction must be the public clock invocation for the block timestamp")] InvalidClockTransaction, + #[error("Second-to-last transaction must be the canonical public fee invocation")] + InvalidFeeTransaction, #[error("Genesis block must contain only public transactions")] NonPublicGenesisTransaction, #[error("State transition failed at transaction {tx_index}: {reason}")] diff --git a/lez/common/Cargo.toml b/lez/common/Cargo.toml index 7582e8858..ca2a58723 100644 --- a/lez/common/Cargo.toml +++ b/lez/common/Cargo.toml @@ -12,6 +12,7 @@ lee.workspace = true lee_core.workspace = true authenticated_transfer_core.workspace = true clock_core.workspace = true +fee_core.workspace = true programs.workspace = true system_accounts.workspace = true diff --git a/lez/common/src/test_utils.rs b/lez/common/src/test_utils.rs index 4a9ab9929..fe03d659e 100644 --- a/lez/common/src/test_utils.rs +++ b/lez/common/src/test_utils.rs @@ -11,7 +11,7 @@ use lee::{Account, PrivateKey, PublicKey, V03State, ValidatedStateDiff}; use crate::{ HashType, block::{Block, HashableBlockData}, - transaction::{LeeTransaction, clock_invocation}, + transaction::{LeeTransaction, clock_invocation, fee_invocation}, }; // Helpers @@ -63,6 +63,9 @@ pub fn produce_dummy_block( prev_hash: Option, mut transactions: Vec, ) -> Block { + transactions.push(LeeTransaction::Public(fee_invocation( + fee_core::Instruction::default(), + ))); transactions.push(LeeTransaction::Public(clock_invocation( id.saturating_mul(100), ))); diff --git a/lez/common/src/transaction.rs b/lez/common/src/transaction.rs index 9970bf7d7..ab3a96e04 100644 --- a/lez/common/src/transaction.rs +++ b/lez/common/src/transaction.rs @@ -91,7 +91,8 @@ impl LeeTransaction { let restricted_modification_accounts = system_accounts::clock_account_ids() .into_iter() - .chain(std::iter::once(system_accounts::faucet_account_id())); + .chain(std::iter::once(system_accounts::faucet_account_id())) + .chain(system_accounts::fee_account_ids()); for account_id in restricted_modification_accounts { validate_doesnt_modify_account(state, &diff, account_id)?; } @@ -244,6 +245,25 @@ pub fn clock_invocation(timestamp: clock_core::Instruction) -> lee::PublicTransa ) } +/// Returns the canonical Fee Program invocation transaction for the given block fee summary. +/// +/// Every valid block must contain exactly one occurrence of this transaction as its +/// second-to-last transaction, immediately before the clock invocation. +#[must_use] +pub fn fee_invocation(summary: fee_core::Instruction) -> lee::PublicTransaction { + let message = lee::public_transaction::Message::try_new( + programs::fee().id(), + system_accounts::fee_account_ids().to_vec(), + vec![], + summary, + ) + .expect("Fee invocation message should always be constructable"); + lee::PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + ) +} + fn validate_doesnt_modify_account( state: &V03State, diff: &ValidatedStateDiff, @@ -380,6 +400,24 @@ mod tests { assert_ne!(faucet, bridge); } + #[test] + fn validate_on_state_rejects_modifying_a_fee_account() { + // Fee accounts are restricted the same way clock accounts are: a native + // transfer crediting any of them must be rejected. + let sender_key = PrivateKey::try_new([5_u8; 32]).expect("valid key"); + let sender_id = AccountId::from(&PublicKey::new_from_private_key(&sender_key)); + let state = V03State::new().with_public_account_balances([(sender_id, 10_000)]); + + for fee_id in system_accounts::fee_account_ids() { + let tx = + create_transaction_native_token_transfer(sender_id, 0, fee_id, 100, &sender_key); + assert!( + tx.validate_on_state(&state, 1, 0).is_err(), + "validate_on_state must reject a transfer that credits fee account {fee_id}", + ); + } + } + #[test] fn validate_on_state_rejects_modifying_a_system_account() { // A native transfer that credits a clock system account *changes* that diff --git a/lez/indexer/service/protocol/src/convert.rs b/lez/indexer/service/protocol/src/convert.rs index 79ea0fe75..c4a5935c0 100644 --- a/lez/indexer/service/protocol/src/convert.rs +++ b/lez/indexer/service/protocol/src/convert.rs @@ -775,6 +775,7 @@ impl From for BlockIngestError { indexer_core::BlockIngestError::InvalidClockTransaction => { Self::InvalidClockTransaction } + indexer_core::BlockIngestError::InvalidFeeTransaction => Self::InvalidFeeTransaction, indexer_core::BlockIngestError::NonPublicGenesisTransaction => { Self::NonPublicGenesisTransaction } diff --git a/lez/indexer/service/protocol/src/lib.rs b/lez/indexer/service/protocol/src/lib.rs index fe1fa525a..895d4c296 100644 --- a/lez/indexer/service/protocol/src/lib.rs +++ b/lez/indexer/service/protocol/src/lib.rs @@ -413,6 +413,7 @@ pub enum BlockIngestError { }, EmptyBlock, InvalidClockTransaction, + InvalidFeeTransaction, NonPublicGenesisTransaction, StateTransition { /// Index of the failing transaction within the block body. diff --git a/lez/programs/Cargo.toml b/lez/programs/Cargo.toml index 707c3d6b4..2727e6b63 100644 --- a/lez/programs/Cargo.toml +++ b/lez/programs/Cargo.toml @@ -34,6 +34,11 @@ name = "faucet" path = "faucet/src/main.rs" required-features = ["programs"] +[[bin]] +name = "fee" +path = "fee/src/main.rs" +required-features = ["programs"] + [[bin]] name = "pinata" path = "pinata/src/main.rs" @@ -106,6 +111,7 @@ programs = [ "dep:bridge_core", "dep:clock_core", "dep:faucet_core", + "dep:fee_core", "dep:token_core", "dep:vault_core", "dep:cross_zone_inbox_core", @@ -125,6 +131,7 @@ authenticated_transfer_core = { workspace = true, optional = true } bridge_core = { workspace = true, optional = true } clock_core = { workspace = true, optional = true } faucet_core = { workspace = true, optional = true } +fee_core = { workspace = true, optional = true } token_core = { workspace = true, optional = true } vault_core = { workspace = true, optional = true } cross_zone_inbox_core = { workspace = true, optional = true } diff --git a/lez/programs/fee/Cargo.toml b/lez/programs/fee/Cargo.toml new file mode 100644 index 000000000..1e2debad6 --- /dev/null +++ b/lez/programs/fee/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "fee_program" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[dependencies] +fee_core.workspace = true +lee_core.workspace = true diff --git a/lez/programs/fee/core/Cargo.toml b/lez/programs/fee/core/Cargo.toml new file mode 100644 index 000000000..03887a654 --- /dev/null +++ b/lez/programs/fee/core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "fee_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +serde.workspace = true diff --git a/lez/programs/fee/core/src/lib.rs b/lez/programs/fee/core/src/lib.rs new file mode 100644 index 000000000..031a903fb --- /dev/null +++ b/lez/programs/fee/core/src/lib.rs @@ -0,0 +1,59 @@ +//! Core data structures and constants for the Fee Program. + +use lee_core::{ + account::AccountId, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +const FEE_STATE_SEED: [u8; 32] = *b"/LEZ/v0.3/FeeSeed/State/0000000/"; +const FEE_ESCROW_SEED: [u8; 32] = *b"/LEZ/v0.3/FeeSeed/Escrow/000000/"; +const FEE_INBOX_SEED: [u8; 32] = *b"/LEZ/v0.3/FeeSeed/Inbox/0000000/"; + +/// Per-block fee summary carried as the fee invocation's instruction and +/// validated byte-for-byte by the transition. All-zero until fee metering +/// lands. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BlockFeeSummary { + pub gas_used_exec: u64, + pub gas_used_stor: u64, + pub revenue_base: u128, + pub revenue_tip: u128, +} + +/// The instruction type for the Fee Program. +pub type Instruction = BlockFeeSummary; + +#[must_use] +pub const fn fee_state_seed() -> PdaSeed { + PdaSeed::new(FEE_STATE_SEED) +} + +#[must_use] +pub const fn fee_escrow_seed() -> PdaSeed { + PdaSeed::new(FEE_ESCROW_SEED) +} + +#[must_use] +pub const fn fee_inbox_seed() -> PdaSeed { + PdaSeed::new(FEE_INBOX_SEED) +} + +/// The fee-state account: base fees, payout window, and carry live in its `data`. +#[must_use] +pub fn compute_fee_state_account_id(fee_program_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&fee_program_id, &fee_state_seed()) +} + +/// The escrow account: its balance is the fee payout escrow. +#[must_use] +pub fn compute_fee_escrow_account_id(fee_program_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&fee_program_id, &fee_escrow_seed()) +} + +/// The inbox account: per-block fee collection point, zero outside the fee +/// invocation. +#[must_use] +pub fn compute_fee_inbox_account_id(fee_program_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&fee_program_id, &fee_inbox_seed()) +} diff --git a/lez/programs/fee/src/main.rs b/lez/programs/fee/src/main.rs new file mode 100644 index 000000000..048776d8c --- /dev/null +++ b/lez/programs/fee/src/main.rs @@ -0,0 +1,61 @@ +//! Fee Program. +//! +//! A system program that owns the fee subsystem's accounts: the fee-state +//! account (base fees, payout window), the escrow account (payout smoothing), +//! and the inbox account (per-block fee collection). Invoked exclusively by the +//! sequencer as the second-to-last transaction of every block, immediately +//! before the clock invocation. Fee accounts are assigned to the fee program at +//! genesis, so no claiming is required here. +//! +//! Skeleton stage: verifies its accounts and echoes them unchanged; the block +//! fee summary is validated byte-for-byte (all-zero) by the transition. + +use fee_core::Instruction; +use lee_core::program::{AccountPostState, ProgramInput, ProgramOutput, read_lee_inputs}; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: _summary, + }, + instruction_words, + ) = read_lee_inputs::(); + + let Ok([pre_state, pre_escrow, pre_inbox]) = <[_; 3]>::try_from(pre_states) else { + panic!("Invalid number of input accounts"); + }; + + // Verify pre-states correspond to the expected fee account IDs. + if pre_state.account_id != fee_core::compute_fee_state_account_id(self_program_id) + || pre_escrow.account_id != fee_core::compute_fee_escrow_account_id(self_program_id) + || pre_inbox.account_id != fee_core::compute_fee_inbox_account_id(self_program_id) + { + panic!("Invalid input accounts"); + } + + // Verify all fee accounts are owned by this program (assigned at genesis). + if pre_state.account.program_owner != self_program_id + || pre_escrow.account.program_owner != self_program_id + || pre_inbox.account.program_owner != self_program_id + { + panic!("Fee accounts must be owned by the fee program"); + } + + let posts = vec![ + AccountPostState::new(pre_state.account.clone()), + AccountPostState::new(pre_escrow.account.clone()), + AccountPostState::new(pre_inbox.account.clone()), + ]; + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pre_state, pre_escrow, pre_inbox], + posts, + ) + .write(); +} diff --git a/lez/programs/src/lib.rs b/lez/programs/src/lib.rs index fb448038f..edf6e39f3 100644 --- a/lez/programs/src/lib.rs +++ b/lez/programs/src/lib.rs @@ -13,9 +13,9 @@ mod inner { AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID, BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID, CLOCK_ELF, CLOCK_ID, CROSS_ZONE_INBOX_ELF, CROSS_ZONE_INBOX_ID, CROSS_ZONE_OUTBOX_ELF, CROSS_ZONE_OUTBOX_ID, FAUCET_ELF, FAUCET_ID, - PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID, PING_RECEIVER_ELF, - PING_RECEIVER_ID, PING_SENDER_ELF, PING_SENDER_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF, - VAULT_ID, WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID, + FEE_ELF, FEE_ID, PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID, + PING_RECEIVER_ELF, PING_RECEIVER_ID, PING_SENDER_ELF, PING_SENDER_ID, TOKEN_ELF, TOKEN_ID, + VAULT_ELF, VAULT_ID, WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID, }; use lee::program::Program; @@ -63,6 +63,12 @@ mod inner { Program::new_unchecked(CLOCK_ID, Cow::Borrowed(CLOCK_ELF)) } + #[must_use] + #[inline] + pub const fn fee() -> Program { + Program::new_unchecked(FEE_ID, Cow::Borrowed(FEE_ELF)) + } + #[must_use] #[inline] pub const fn ata() -> Program { @@ -161,6 +167,7 @@ mod inner { (ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID), (CLOCK_ELF, CLOCK_ID), (FAUCET_ELF, FAUCET_ID), + (FEE_ELF, FEE_ID), (BRIDGE_ELF, BRIDGE_ID), (PINATA_ELF, PINATA_ID), (PINATA_TOKEN_ELF, PINATA_TOKEN_ID), diff --git a/lez/sequencer/core/Cargo.toml b/lez/sequencer/core/Cargo.toml index 7011577cf..f838e2dfb 100644 --- a/lez/sequencer/core/Cargo.toml +++ b/lez/sequencer/core/Cargo.toml @@ -18,6 +18,7 @@ mempool.workspace = true logos-blockchain-zone-sdk.workspace = true testnet_initial_state.workspace = true faucet_core.workspace = true +fee_core.workspace = true bridge_core.workspace = true vault_core.workspace = true programs.workspace = true diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 6d479ef04..b23586e85 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -13,7 +13,7 @@ use chain_state::{ use common::{ HashType, block::{BedrockStatus, Block, BlockMeta, HashableBlockData}, - transaction::{LeeTransaction, clock_invocation}, + transaction::{LeeTransaction, clock_invocation, fee_invocation}, }; use config::{GenesisAction, SequencerConfig}; use cross_zone_inbox_core::CrossZoneMessage; @@ -878,6 +878,8 @@ impl SequencerCore { let new_block_timestamp = u64::try_from(chrono::Utc::now().timestamp_millis()) .expect("Timestamp must be positive"); + let fee_tx = fee_invocation(fee_core::Instruction::default()); + let fee_lee_tx = LeeTransaction::Public(fee_tx.clone()); let clock_tx = clock_invocation(new_block_timestamp); let clock_lee_tx = LeeTransaction::Public(clock_tx.clone()); @@ -898,6 +900,7 @@ impl SequencerCore { let temp_valid_transactions = [ valid_transactions.as_slice(), std::slice::from_ref(&tx), + std::slice::from_ref(&fee_lee_tx), std::slice::from_ref(&clock_lee_tx), ] .concat(); @@ -926,7 +929,7 @@ impl SequencerCore { if from_store && !self.fits_in_an_empty_block( &tx, - &clock_lee_tx, + &[fee_lee_tx.clone(), clock_lee_tx.clone()], new_block_height, prev_block_hash, new_block_timestamp, @@ -988,6 +991,11 @@ impl SequencerCore { } } + working_state + .transition_from_public_transaction(&fee_tx, new_block_height, new_block_timestamp) + .context("Fee transaction failed. Aborting block production.")?; + valid_transactions.push(fee_lee_tx); + working_state .transition_from_public_transaction(&clock_tx, new_block_height, new_block_timestamp) .context("Clock transaction failed. Aborting block production.")?; @@ -1072,8 +1080,8 @@ impl SequencerCore { &self.block_publisher } - /// Whether a block carrying nothing but `tx` and the clock would be within - /// the size limit. + /// Whether a block carrying nothing but `tx` and the appended system + /// transactions (fee and clock) would be within the size limit. /// /// Distinguishes "does not fit in this block" from "does not fit in any /// block". The first is an ordinary deferral; the second, for a transaction @@ -1082,14 +1090,14 @@ impl SequencerCore { fn fits_in_an_empty_block( &self, tx: &LeeTransaction, - clock_tx: &LeeTransaction, + system_txs: &[LeeTransaction], block_id: u64, prev_block_hash: HashType, timestamp: u64, ) -> Result { let alone = HashableBlockData { block_id, - transactions: vec![tx.clone(), clock_tx.clone()], + transactions: [std::slice::from_ref(tx), system_txs].concat(), prev_block_hash, timestamp, }; @@ -1580,6 +1588,9 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec bool { - cross_zone::is_sequencer_only_program(program_id) + cross_zone::is_sequencer_only_program(program_id) || program_id == programs::fee().id() } fn build_supply_account_genesis_transaction( @@ -1700,8 +1712,9 @@ fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Resu /// User transactions of an orphaned block to return to the mempool: everything /// except the trailing clock tx, sequencer-generated bridge deposits (replayed -/// from their own bedrock events) and sequencer-only cross-zone txs (replayed -/// by the watcher; the ingress guard rejects them as `User`). +/// from their own bedrock events) and sequencer-only txs — cross-zone dispatches +/// (replayed by the watcher) and the fee tx (regenerated every block; the +/// ingress guard rejects them as `User`). fn resubmittable_txs(block: &Block) -> Vec { let Some((_clock, rest)) = block.body.transactions.split_last() else { return Vec::new(); diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 6dff0fe82..8dc42f86b 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -6,7 +6,7 @@ use common::{ HashType, block::{BedrockStatus, Block, HashableBlockData}, test_utils::sequencer_sign_key_for_testing, - transaction::{LeeTransaction, clock_invocation}, + transaction::{LeeTransaction, clock_invocation, fee_invocation}, }; use lee::{ Account, AccountId, Data, PrivateKey, PublicKey, PublicTransaction, V03State, program::Program, @@ -97,8 +97,9 @@ fn setup_sequencer_config() -> SequencerConfig { } #[test] -fn only_the_cross_zone_inbox_is_sequencer_only() { +fn only_the_cross_zone_inbox_and_fee_are_sequencer_only() { assert!(is_sequencer_only_program(programs::cross_zone_inbox().id())); + assert!(is_sequencer_only_program(programs::fee().id())); assert!(!is_sequencer_only_program( programs::cross_zone_outbox().id() )); @@ -107,6 +108,11 @@ fn only_the_cross_zone_inbox_is_sequencer_only() { assert!(!is_sequencer_only_program(programs::clock().id())); } +/// The forced zero-fee invocation appended to every block before the clock. +fn zero_fee_tx() -> LeeTransaction { + LeeTransaction::Public(fee_invocation(fee_core::Instruction::default())) +} + fn create_signing_key_for_account1() -> lee::PrivateKey { initial_pub_accounts_private_keys()[0].pub_sign_key.clone() } @@ -1165,11 +1171,12 @@ async fn replay_transactions_are_rejected_in_the_same_block() { .unwrap() .unwrap(); - // Only one user tx should be included; the clock tx is always appended last. + // Only one user tx should be included; the fee and clock txs are always appended last. assert_eq!( block.body.transactions, vec![ tx.clone(), + zero_fee_tx(), LeeTransaction::Public(clock_invocation(block.header.timestamp)) ] ); @@ -1203,6 +1210,7 @@ async fn replay_transactions_are_rejected_in_different_blocks() { block.body.transactions, vec![ tx.clone(), + zero_fee_tx(), LeeTransaction::Public(clock_invocation(block.header.timestamp)) ] ); @@ -1218,12 +1226,13 @@ async fn replay_transactions_are_rejected_in_different_blocks() { .get_block_at_id(sequencer.chain_height()) .unwrap() .unwrap(); - // The replay is rejected, so only the clock tx is in the block. + // The replay is rejected, so only the fee and clock txs are in the block. assert_eq!( block.body.transactions, - vec![LeeTransaction::Public(clock_invocation( - block.header.timestamp - ))] + vec![ + zero_fee_tx(), + LeeTransaction::Public(clock_invocation(block.header.timestamp)) + ] ); } @@ -1264,6 +1273,7 @@ async fn restart_from_storage() { block.body.transactions, vec![ tx.clone(), + zero_fee_tx(), LeeTransaction::Public(clock_invocation(block.header.timestamp)) ] ); @@ -1384,9 +1394,10 @@ async fn produce_block_with_correct_prev_meta_after_restart() { new_block.body.transactions, vec![ tx, + zero_fee_tx(), LeeTransaction::Public(clock_invocation(new_block.header.timestamp)) ], - "New block should contain the submitted transaction and the clock invocation" + "New block should contain the submitted transaction and the fee and clock invocations" ); } @@ -1428,12 +1439,13 @@ async fn transactions_touching_clock_account_are_dropped_from_block() { .unwrap() .unwrap(); - // Both transactions were dropped. Only the system-appended clock tx remains. + // Both transactions were dropped. Only the system-appended fee and clock txs remain. assert_eq!( block.body.transactions, - vec![LeeTransaction::Public(clock_invocation( - block.header.timestamp - ))] + vec![ + zero_fee_tx(), + LeeTransaction::Public(clock_invocation(block.header.timestamp)) + ] ); } @@ -1483,12 +1495,13 @@ async fn user_tx_that_chain_calls_clock_is_dropped() { .unwrap() .unwrap(); - // The user tx must have been dropped; only the mandatory clock invocation remains. + // The user tx must have been dropped; only the mandatory fee and clock invocations remain. assert_eq!( block.body.transactions, - vec![LeeTransaction::Public(clock_invocation( - block.header.timestamp - ))] + vec![ + zero_fee_tx(), + LeeTransaction::Public(clock_invocation(block.header.timestamp)) + ] ); } diff --git a/lez/storage/Cargo.toml b/lez/storage/Cargo.toml index 18c58e115..e72d54e5f 100644 --- a/lez/storage/Cargo.toml +++ b/lez/storage/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] common.workspace = true +fee_core.workspace = true lee.workspace = true thiserror.workspace = true diff --git a/lez/storage/src/indexer/mod.rs b/lez/storage/src/indexer/mod.rs index 0955ef268..bb3fda56a 100644 --- a/lez/storage/src/indexer/mod.rs +++ b/lez/storage/src/indexer/mod.rs @@ -2,7 +2,7 @@ use std::{path::Path, sync::Arc}; use common::{ block::Block, - transaction::{LeeTransaction, clock_invocation}, + transaction::{LeeTransaction, clock_invocation, fee_invocation}, }; use lee::{GENESIS_BLOCK_ID, V03State}; use log::warn; @@ -213,6 +213,20 @@ fn apply_block_transactions(mut block: Block, state: &mut V03State) -> DbResult< )); } + let expected_fee = LeeTransaction::Public(fee_invocation(fee_core::Instruction::default())); + + let fee_tx = block.body.transactions.pop().ok_or_else(|| { + DbError::db_interaction_error( + "Block must contain fee transaction before the clock transaction".to_owned(), + ) + })?; + + if fee_tx != expected_fee { + return Err(DbError::db_interaction_error( + "Second-to-last transaction in block must be the fee invocation".to_owned(), + )); + } + for transaction in block.body.transactions { if block.header.block_id == GENESIS_BLOCK_ID { let genesis_tx = match transaction { @@ -245,6 +259,24 @@ fn apply_block_transactions(mut block: Block, state: &mut V03State) -> DbResult< } } + let LeeTransaction::Public(fee_public_tx) = fee_tx else { + return Err(DbError::db_interaction_error( + "Fee invocation must be a public transaction".to_owned(), + )); + }; + + state + .transition_from_public_transaction( + &fee_public_tx, + block.header.block_id, + block.header.timestamp, + ) + .map_err(|err| { + DbError::db_interaction_error(format!( + "fee transaction execution failed with err {err:?}" + )) + })?; + let LeeTransaction::Public(clock_public_tx) = clock_tx else { return Err(DbError::db_interaction_error( "Clock invocation must be a public transaction".to_owned(), diff --git a/lez/storage/src/indexer/tests.rs b/lez/storage/src/indexer/tests.rs index d87aaf1ca..7bcbf4e79 100644 --- a/lez/storage/src/indexer/tests.rs +++ b/lez/storage/src/indexer/tests.rs @@ -41,10 +41,17 @@ fn initial_state() -> lee::V03State { for clock_id in system_accounts::clock_account_ids() { public_accounts.push((clock_id, system_accounts::clock_account())); } + for fee_id in system_accounts::fee_account_ids() { + public_accounts.push((fee_id, system_accounts::fee_account())); + } lee::V03State::new() .with_public_accounts(public_accounts) - .with_programs([programs::authenticated_transfer(), programs::clock()]) + .with_programs([ + programs::authenticated_transfer(), + programs::clock(), + programs::fee(), + ]) } #[test] diff --git a/lez/system_accounts/Cargo.toml b/lez/system_accounts/Cargo.toml index 093e64553..5b0e03189 100644 --- a/lez/system_accounts/Cargo.toml +++ b/lez/system_accounts/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] lee_core.workspace = true faucet_core.workspace = true +fee_core.workspace = true bridge_core.workspace = true clock_core.workspace = true programs.workspace = true diff --git a/lez/system_accounts/src/lib.rs b/lez/system_accounts/src/lib.rs index 3b6dd5af0..b8a0e7ee8 100644 --- a/lez/system_accounts/src/lib.rs +++ b/lez/system_accounts/src/lib.rs @@ -50,6 +50,39 @@ pub fn bridge_account() -> Account { } } +#[must_use] +pub fn fee_state_account_id() -> AccountId { + fee_core::compute_fee_state_account_id(programs::fee().id()) +} + +#[must_use] +pub fn fee_escrow_account_id() -> AccountId { + fee_core::compute_fee_escrow_account_id(programs::fee().id()) +} + +#[must_use] +pub fn fee_inbox_account_id() -> AccountId { + fee_core::compute_fee_inbox_account_id(programs::fee().id()) +} + +/// Fee program account IDs in the order expected by the fee program. +#[must_use] +pub fn fee_account_ids() -> [AccountId; 3] { + [ + fee_state_account_id(), + fee_escrow_account_id(), + fee_inbox_account_id(), + ] +} + +#[must_use] +pub fn fee_account() -> Account { + Account { + program_owner: programs::fee().id(), + ..Account::default() + } +} + #[must_use] pub const fn clock_account_ids() -> [AccountId; 3] { clock_core::CLOCK_PROGRAM_ACCOUNT_IDS diff --git a/lez/testnet_initial_state/src/lib.rs b/lez/testnet_initial_state/src/lib.rs index 8ea71e22b..65b5c26fe 100644 --- a/lez/testnet_initial_state/src/lib.rs +++ b/lez/testnet_initial_state/src/lib.rs @@ -212,6 +212,11 @@ fn initial_public_accounts() -> HashMap { .into_iter() .map(|clock_id| (clock_id, system_accounts::clock_account())), ) + .chain( + system_accounts::fee_account_ids() + .into_iter() + .map(|fee_id| (fee_id, system_accounts::fee_account())), + ) .collect() } @@ -221,6 +226,7 @@ fn initial_programs() -> Vec { programs::token(), programs::amm(), programs::clock(), + programs::fee(), programs::ata(), programs::vault(), programs::faucet(), @@ -407,6 +413,24 @@ mod tests { ); } + #[test] + fn genesis_fee_accounts_are_registered_and_owned() { + let state = initial_state(); + let fee_program_id = programs::fee().id(); + + let ids = system_accounts::fee_account_ids(); + // state, escrow, inbox — all distinct, all non-default. + for (i, id) in ids.iter().enumerate() { + assert_ne!(*id, AccountId::default()); + for other in &ids[i + 1..] { + assert_ne!(id, other); + } + let account = state.get_account_by_id(*id); + assert_eq!(account.program_owner, fee_program_id); + assert_eq!(account.balance, 0); + } + } + #[test] fn genesis_system_accounts_have_expected_contents() { // System-account IDs must be distinct and non-default, and the genesis diff --git a/test_fixtures/fixtures/prebuilt_sequencer_db.dump b/test_fixtures/fixtures/prebuilt_sequencer_db.dump index 0fe43eded..fd0a2ad73 100644 Binary files a/test_fixtures/fixtures/prebuilt_sequencer_db.dump and b/test_fixtures/fixtures/prebuilt_sequencer_db.dump differ