From e04a238567ba24ff02aa6fe840a1dbf7ca821171 Mon Sep 17 00:00:00 2001 From: erhant Date: Thu, 13 Aug 2026 13:58:31 +0300 Subject: [PATCH] feat(sequencer)!: screen transactions for fee validity at RPC ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject fee-invalid transactions at submission instead of letting them rot in the mempool, and let clients price a transaction before signing. The screen runs against the head state and is anti-spam, not consensus: the block transition and the builder remain the enforcement points. It is at least as strict as the builder's pre-screen, so nothing admitted is unbuildable — both now read the same `exceeds_empty_block` bound. Rejections are machine-readable: `AdmissionRejection` lives in sequencer_service_protocol with a stable per-check error code, and travels serialized in the JSON-RPC error's `data` field so clients branch on values rather than parse prose. Adds `getFeeState`, returning current base fees, the band the next block's can land in, the private-transaction quote, and the block caps. The base-fee step is extracted into `fee_core::stepped_base_fees` so the quote and the block transition cannot drift. Rejection payloads encode their u128 fields as decimal strings: the `check` tag makes the enum internally tagged, and serde cannot read a bare u128 back through that path. BREAKING CHANGE: submissions that were previously accepted into the mempool are now rejected at ingest when their declared gas or bytes exceed the block caps, their max_fee is below the reserve at current base fees, or their payer is unauthorized or cannot fund the reserve. --- .claude/lez-fees/ANALYSIS.md | 309 +++++++ .claude/lez-fees/EXIT-CODES.md | 378 +++++++++ .claude/lez-fees/INCREMENTIAL.md | 457 ++++++++++ .claude/lez-fees/PLAN.md | 150 ++++ .claude/lez-fees/SPECS.md | 978 ++++++++++++++++++++++ Cargo.lock | 11 + FEES.md | 4 + lee/state_machine/src/state/mod.rs | 25 +- lez/fee_core/src/lib.rs | 2 +- lez/fee_core/src/update.rs | 43 + lez/sequencer/core/src/lib.rs | 120 ++- lez/sequencer/core/src/tests.rs | 24 +- lez/sequencer/service/Cargo.toml | 11 + lez/sequencer/service/protocol/Cargo.toml | 5 + lez/sequencer/service/protocol/src/lib.rs | 230 ++++- lez/sequencer/service/rpc/src/lib.rs | 9 +- lez/sequencer/service/src/fees.rs | 603 +++++++++++++ lez/sequencer/service/src/lib.rs | 1 + lez/sequencer/service/src/service.rs | 190 +++-- 19 files changed, 3424 insertions(+), 126 deletions(-) create mode 100644 .claude/lez-fees/ANALYSIS.md create mode 100644 .claude/lez-fees/EXIT-CODES.md create mode 100644 .claude/lez-fees/INCREMENTIAL.md create mode 100644 .claude/lez-fees/PLAN.md create mode 100644 .claude/lez-fees/SPECS.md create mode 100644 lez/sequencer/service/src/fees.rs 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..d3dc9ea02 --- /dev/null +++ b/.claude/lez-fees/SPECS.md @@ -0,0 +1,978 @@ + + +# 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 (wallet and system-program design; this document only requires that the reservation succeed); 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`. + +`max_fee` bounds the sender's exposure: a transaction signed at low base fees cannot be included later at prices whose reservation exceeds its cap. 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. + +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. 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 a2fa39eaa..7da305935 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9559,15 +9559,20 @@ name = "sequencer_service" version = "0.1.0" dependencies = [ "anyhow", + "authenticated_transfer_core", "borsh", + "bridge_core", "bytesize", + "chain_state", "clap", "common", "env_logger", + "fee_core", "futures", "hex", "jsonrpsee", "lee", + "lee_core", "log", "mempool", "metrics-exporter-prometheus", @@ -9576,8 +9581,11 @@ dependencies = [ "sequencer_service_metrics", "sequencer_service_protocol", "sequencer_service_rpc", + "system_accounts", + "testnet_initial_state", "tokio", "tokio-util", + "vault_core", ] [[package]] @@ -9595,7 +9603,10 @@ dependencies = [ "hex", "lee", "lee_core", + "serde", + "serde_json", "serde_with", + "thiserror 2.0.18", ] [[package]] diff --git a/FEES.md b/FEES.md index 5e27fb00e..702055011 100644 --- a/FEES.md +++ b/FEES.md @@ -18,6 +18,10 @@ The three uncharged rows — private, deployment, and full sweep — are deliber **Wallet defaults** (`lez/wallet/src/lib.rs`): `gas_limit` is `DEFAULT_GAS_LIMIT` = 2,000,000 (about three times the widest measured program), `tip` is 0, and `max_fee` is `DEFAULT_MAX_FEE`, priced at eight times the genesis minimum base fee. `max_fee` caps the *reservation*, not the fee: it stops a transaction signed at low base fees from being included later at prices the sender never agreed to. In practice a wallet-built transaction stops being includable once base fees rise past roughly 67 atomic units per gas — about twenty-two consecutive fully congested blocks away from genesis (the up-step is a floored `b / 8` with a guaranteed +1, so the integer sequence 8, 9, …, 64, 72 takes 22 blocks to clear it). Unused gas is released at settlement; only the reservation is held meanwhile. +**Submission is screened.** `sendTransaction` refuses, under its own JSON-RPC error code and with the values that decided it in the error's `data`, a transaction the head state says no block would include: declared gas or bytes past the block caps (every class that counts against them — system transactions are exempt from the caps here exactly as they are in the block transition), a `max_fee` below the reservation at current base fees, a payer nothing authorizes or that visibly cannot fund the reservation, and a charged transaction whose only signature is the fee witness (it would burn no nonce). These are anti-spam checks, not consensus — base fees and balances move, so a transaction admitted now can still be rejected by the block transition later. + +Admission judges funding and classification *at the head state*, which makes submission order-sensitive: a sponsor funded by a transfer that is still in the mempool, or a vault sweep submitted before the deposit-mint that fills the vault has landed, is rejected now and admissible a block later. Wallets should treat a `PayerCannotFund` (or a sweep rejection) as "retry after the next block", not as final. `getFeeState` returns the current base fees, the band the next block's can land in, what a private transaction's fixed gas prices out at, and the block caps: the inputs for choosing `gas_limit` and `max_fee` before signing. + **Operators: initialize the producer account before it produces its first charged block.** Block fees are credited to the account derived from the sequencer's block-producing key. Crediting an account that does not exist yet materializes it with a balance but *no owning program*, and nothing can adopt it afterwards: `Initialize` requires an untouched account, and a transfer to it cannot claim it either. A producer that earns before it initializes has its payouts stranded permanently, with no recovery — the funds are unspendable and the only way out is a fresh key. So run the ordinary initialization for the producer account first, sponsored by a funded account (the producer has nothing of its own to pay that transaction's fee with; the fee witness exists for exactly this). The sequencer logs a warning at startup if its producer account is missing or still default-owned. Fee constants are protocol-fixed in `lez/fee_core/src/params.rs`. Changing any of them is a protocol-version change, and nodes built from different values will fork; the genesis fingerprint the sequencer logs at startup covers them, so two nodes' lines can be compared by eye. diff --git a/lee/state_machine/src/state/mod.rs b/lee/state_machine/src/state/mod.rs index 73db92560..ba0481cc3 100644 --- a/lee/state_machine/src/state/mod.rs +++ b/lee/state_machine/src/state/mod.rs @@ -1,13 +1,7 @@ use std::collections::{BTreeSet, HashMap, HashSet}; use borsh::{BorshDeserialize, BorshSerialize}; -use fee_core::{ - FeeError, FeeState, - params::{ - BASE_FEE_EXEC_MAX, BASE_FEE_EXEC_MIN, BASE_FEE_STOR_MAX, BASE_FEE_STOR_MIN, D_EXEC, D_STOR, - MAX_GAS_EXEC, TARGET_GAS_EXEC, TARGET_GAS_STOR, - }, -}; +use fee_core::{FeeError, FeeState, params::MAX_GAS_EXEC}; use lee_core::{ BlockId, Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, MembershipProof, Nullifier, Timestamp, @@ -388,22 +382,7 @@ impl V03State { /// Moves both base fees to their values for the next block (SPECS §Base-fee update). pub fn update_base_fees(&mut self, gas_used_exec: u64, gas_used_stor: u64) { - self.fee_state.base_fee_exec = fee_core::next_base_fee( - self.fee_state.base_fee_exec, - gas_used_exec, - TARGET_GAS_EXEC, - D_EXEC, - BASE_FEE_EXEC_MIN, - BASE_FEE_EXEC_MAX, - ); - self.fee_state.base_fee_stor = fee_core::next_base_fee( - self.fee_state.base_fee_stor, - gas_used_stor, - TARGET_GAS_STOR, - D_STOR, - BASE_FEE_STOR_MIN, - BASE_FEE_STOR_MAX, - ); + fee_core::step_base_fees(&mut self.fee_state, gas_used_exec, gas_used_stor); } /// Wholesale mutable access to the fee state. diff --git a/lez/fee_core/src/lib.rs b/lez/fee_core/src/lib.rs index 558843abf..7bdeb751b 100644 --- a/lez/fee_core/src/lib.rs +++ b/lez/fee_core/src/lib.rs @@ -11,7 +11,7 @@ pub use assess::{FeeTxView, PayerId, fee_actual_base, fee_reserve, gas_stor}; pub use distribute::{distribute, record_revenue, settle_payout}; pub use error::{ConsensusFaultError, FeeError, InvalidBlockError}; pub use state::FeeState; -pub use update::next_base_fee; +pub use update::{next_base_fee, step_base_fees, stepped_base_fees}; pub use validity::{ DeploymentFeePolicy, accumulate_gas_used, authorize_payer, authorize_private_payer, deployment_policy, validate_static_block, validate_static_tx, diff --git a/lez/fee_core/src/update.rs b/lez/fee_core/src/update.rs index 72638a95f..cc5b53cbd 100644 --- a/lez/fee_core/src/update.rs +++ b/lez/fee_core/src/update.rs @@ -6,6 +6,49 @@ use std::cmp::Ordering; +use crate::{ + params::{ + BASE_FEE_EXEC_MAX, BASE_FEE_EXEC_MIN, BASE_FEE_STOR_MAX, BASE_FEE_STOR_MIN, D_EXEC, D_STOR, + TARGET_GAS_EXEC, TARGET_GAS_STOR, + }, + state::FeeState, +}; + +/// Both base fees one block on, at the gas the block used. +/// +/// The single place each resource is wired to its own target, denominator and saturation bounds. +/// The block transition moves the fee state through [`step_base_fees`]; anything that *quotes* the +/// next block's prices without moving state (the sequencer's fee RPC) reads them here, so a quote +/// cannot drift from what the transition will actually do. +#[must_use] +pub fn stepped_base_fees(state: &FeeState, gas_used_exec: u64, gas_used_stor: u64) -> (u64, u64) { + ( + next_base_fee( + state.base_fee_exec, + gas_used_exec, + TARGET_GAS_EXEC, + D_EXEC, + BASE_FEE_EXEC_MIN, + BASE_FEE_EXEC_MAX, + ), + next_base_fee( + state.base_fee_stor, + gas_used_stor, + TARGET_GAS_STOR, + D_STOR, + BASE_FEE_STOR_MIN, + BASE_FEE_STOR_MAX, + ), + ) +} + +/// Moves both base fees to their values for the next block (SPECS §Base-fee update). +pub fn step_base_fees(state: &mut FeeState, gas_used_exec: u64, gas_used_stor: u64) { + let (base_fee_exec, base_fee_stor) = stepped_base_fees(state, gas_used_exec, gas_used_stor); + state.base_fee_exec = base_fee_exec; + state.base_fee_stor = base_fee_stor; +} + /// Computes the next base fee for one resource. /// /// Takes the current value `b`, gas used `g`, `target`, adjustment diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index a072c97d9..1ec84b244 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -95,6 +95,23 @@ enum CapScreen { Drop, } +/// One of the two block resources. Named rather than spelled out at each use so callers that have +/// to report which cap was hit (the RPC's admission errors) can map it without matching prose. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BlockResource { + ExecutionGas, + StorageGas, +} + +impl std::fmt::Display for BlockResource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ExecutionGas => write!(f, "execution gas"), + Self::StorageGas => write!(f, "storage gas"), + } + } +} + /// The block's running gas totals, against the two caps `apply_block_to_state` enforces. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct BlockGasUsed { @@ -110,12 +127,12 @@ impl BlockGasUsed { const fn would_exceed( self, contribution: chain_state::CapContribution, - ) -> Option<&'static str> { + ) -> Option { if self.exec.saturating_add(contribution.gas_exec) > MAX_GAS_EXEC { - return Some("execution gas"); + return Some(BlockResource::ExecutionGas); } if self.stor.saturating_add(contribution.gas_stor) > MAX_GAS_STOR { - return Some("storage gas"); + return Some(BlockResource::StorageGas); } None } @@ -827,7 +844,7 @@ impl SequencerCore { // // Sized off the same `chain_state::classify` the apply path uses, so a block this // builder fills to the brim is one `apply_block_to_state` accepts. The caller's - // pre-screen ([`Self::static_cap_bound`]) has already turned away everything whose + // pre-screen ([`static_cap_bound`]) has already turned away everything whose // declared size cannot fit; what is left is the exact check, which only the classes // whose execution gas is metered rather than declared can still fail. if let Some(contribution) = chain_state::cap_contribution(tx, state, outcome.cycles) @@ -1295,16 +1312,16 @@ impl SequencerCore { /// ever hold it. Deferring that one would requeue it for ever, and requeued work is drained /// again first thing next turn: a handful would fill every batch and the mempool behind them /// would never be reached again — a stall anyone can trigger by declaring - /// `gas_limit = u64::MAX`, which costs nothing and needs no balance. Nothing upstream rejects - /// it: RPC admission bounds neither the declared gas nor the wire size against the block caps, - /// and the static fee-validity check that would catch it runs inside - /// [`Self::apply_mempool_transaction`], which this pre-screen deliberately precedes. + /// `gas_limit = u64::MAX`, which costs nothing and needs no balance. RPC admission rejects + /// those at ingest off the same [`exceeds_empty_block`] bound, so this arm is the backstop for + /// what is already in a mempool (or arrived through another path), not the only guard. fn screen_cap_budget( tx: &LeeTransaction, state: &lee::V03State, gas_used: BlockGasUsed, ) -> CapScreen { - let Some(bound) = Self::static_cap_bound(tx, state) else { + let Some(bound) = static_cap_bound(tx, state, chain_state::charged_fee_view(tx, state)) + else { return CapScreen::Admit; }; let Some(over) = gas_used.would_exceed(bound) else { @@ -1312,7 +1329,7 @@ impl SequencerCore { }; let tx_hash = tx.hash(); - if let Some(over_alone) = BlockGasUsed::default().would_exceed(bound) { + if let Some(over_alone) = exceeds_empty_block(bound) { error!( "Transaction with hash {tx_hash} declares more {over_alone} than an entire block \ may hold; dropping it rather than deferring it for ever", @@ -1327,39 +1344,6 @@ impl SequencerCore { CapScreen::Defer } - /// The most `tx` can contribute to the two block totals, known before it executes. `None` for a - /// cap-exempt transaction (the sequencer's own injections), which contributes to neither. - /// - /// Read off the same shared classification the apply path uses, per class: - /// - /// - **charged**: the `gas_limit` it declares (execution is clamped to it, so its real - /// contribution can only be smaller) and its wire size, which is exact; - /// - **private and deployment**: protocol constants and wire size — exact, so the pre-screen is - /// the whole answer for them; - /// - **full vault sweep**: storage only. Its execution gas is metered rather than declared, so - /// the pre-screen uses zero for it and the post-execution check is what bounds it. - /// - /// Never an under-estimate of the storage side and never below the *declared* execution bound, - /// which is what makes this safe to defer on: it can only turn away transactions the exact - /// check would also have deferred, plus charged ones that would have under-run their own - /// declared limit. - fn static_cap_bound( - tx: &LeeTransaction, - state: &lee::V03State, - ) -> Option { - if let Some(view) = chain_state::charged_fee_view(tx, state) - && let fee_core::FeeTxView::Public { gas_limit, .. } = view - { - return Some(chain_state::CapContribution { - gas_exec: gas_limit, - gas_stor: fee_core::gas_stor(&view), - }); - } - // Zero cycles: ignored by every class whose execution gas is a constant, and the floor for - // the one class where it is metered. - chain_state::cap_contribution(tx, state, 0) - } - /// Reads the current head state under the lock without cloning it, so callers /// reuse `V03State`'s own API (accounts, nonces, proofs) with no whole-state copy. pub fn with_state(&self, f: impl FnOnce(&lee::V03State) -> R) -> R { @@ -1557,6 +1541,56 @@ struct BlockWithMeta { gas_used: BlockGasUsed, } +/// The most `tx` can contribute to the two block totals, known before it executes. `None` for a +/// cap-exempt transaction (the sequencer's own injections), which contributes to neither. +/// +/// Read off the same shared classification the apply path uses, per class: +/// +/// - **charged**: the `gas_limit` it declares (execution is clamped to it, so its real contribution +/// can only be smaller) and its wire size, which is exact; +/// - **private and deployment**: protocol constants and wire size — exact, so the pre-screen is the +/// whole answer for them; +/// - **full vault sweep**: storage only. Its execution gas is metered rather than declared, so the +/// pre-screen uses zero for it and the post-execution check is what bounds it. +/// +/// Never an under-estimate of the storage side and never below the *declared* execution bound, +/// which is what makes this safe to defer on: it can only turn away transactions the exact +/// check would also have deferred, plus charged ones that would have under-run their own +/// declared limit. +/// +/// `charged` is the transaction's charged view, which the caller passes in rather than having it +/// recomputed: classifying decodes the transaction's instruction, and the ingest path already +/// holds one. +#[must_use] +pub fn static_cap_bound( + tx: &LeeTransaction, + state: &lee::V03State, + charged: Option, +) -> Option { + if let Some(view) = charged + && let fee_core::FeeTxView::Public { gas_limit, .. } = view + { + return Some(chain_state::CapContribution { + gas_exec: gas_limit, + gas_stor: fee_core::gas_stor(&view), + }); + } + // Zero cycles: ignored by every class whose execution gas is a constant, and the floor for + // the one class where it is metered. + chain_state::cap_contribution(tx, state, 0) +} + +/// Which block cap a contribution this size busts on an *empty* block, or `None` if some block +/// could hold it. +/// +/// The line between "does not fit this block" (an ordinary deferral) and "fits no block": the +/// builder drops the second kind rather than requeueing it for ever, and RPC admission turns it +/// away at ingest. Both sides read this one bound so they cannot disagree about which is which. +#[must_use] +pub fn exceeds_empty_block(contribution: chain_state::CapContribution) -> Option { + BlockGasUsed::default().would_exceed(contribution) +} + /// Orders one drained batch by what its transactions bid, without ever reordering a payer's /// transactions against each other. /// diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 9b841128b..b211269c2 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -31,7 +31,7 @@ use tempfile::tempdir; use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; use crate::{ - MAX_DISPATCHES_PER_BLOCK, RETIRE_DISPATCH_AFTER_FAILURES, TransactionOrigin, + BlockResource, MAX_DISPATCHES_PER_BLOCK, RETIRE_DISPATCH_AFTER_FAILURES, TransactionOrigin, apply_follow_update, block_publisher::FollowUpdate, block_store::SequencerStore, @@ -1100,21 +1100,30 @@ fn block_gas_used_defers_the_transaction_that_would_bust_either_cap() { // Exactly full is still valid; one more of either is not. assert_eq!(used.would_exceed(contribution(0, 0)), None); - assert_eq!(used.would_exceed(contribution(1, 0)), Some("execution gas")); - assert_eq!(used.would_exceed(contribution(0, 1)), Some("storage gas")); + assert_eq!( + used.would_exceed(contribution(1, 0)), + Some(BlockResource::ExecutionGas) + ); + assert_eq!( + used.would_exceed(contribution(0, 1)), + Some(BlockResource::StorageGas) + ); // The two ceilings are separate: a block full of storage gas has execution gas to spare. let mut used = super::BlockGasUsed::default(); used.add(contribution(0, MAX_GAS_STOR)); assert_eq!(used.would_exceed(contribution(MAX_GAS_EXEC, 0)), None); - assert_eq!(used.would_exceed(contribution(0, 1)), Some("storage gas")); + assert_eq!( + used.would_exceed(contribution(0, 1)), + Some(BlockResource::StorageGas) + ); // Saturating, so a contribution that would overflow the total is still just "over the cap". let mut used = super::BlockGasUsed::default(); used.add(contribution(1, 1)); assert_eq!( used.would_exceed(contribution(u64::MAX, 0)), - Some("execution gas") + Some(BlockResource::ExecutionGas) ); } @@ -1368,8 +1377,9 @@ async fn deferring_one_transaction_of_a_payer_defers_the_rest_of_its_chain() { /// must be dropped rather than deferred. /// /// Deferring it would requeue it for ever, and requeued work is drained first thing next turn: a -/// handful of these — free to submit, needing no balance, rejected by nothing upstream — would fill -/// every batch and the mempool behind them would never be reached again. +/// handful of these — free to submit and needing no balance — would fill every batch and the +/// mempool behind them would never be reached again. RPC admission turns them away at the door; +/// this is the backstop for everything that reaches a mempool by another route. #[tokio::test] async fn a_transaction_larger_than_any_block_is_dropped_rather_than_deferred_for_ever() { // Raised so the storage case below is decided by the consensus cap, not by the local envelope. diff --git a/lez/sequencer/service/Cargo.toml b/lez/sequencer/service/Cargo.toml index 396fbe6b0..fc43069d6 100644 --- a/lez/sequencer/service/Cargo.toml +++ b/lez/sequencer/service/Cargo.toml @@ -9,7 +9,9 @@ license = { workspace = true } workspace = true [dependencies] +chain_state.workspace = true common.workspace = true +fee_core.workspace = true lee.workspace = true mempool.workspace = true sequencer_core = { workspace = true, features = ["testnet"] } @@ -31,6 +33,15 @@ futures.workspace = true bytesize.workspace = true borsh.workspace = true +[dev-dependencies] +authenticated_transfer_core.workspace = true +bridge_core.workspace = true +lee = { workspace = true, features = ["test-utils"] } +lee_core.workspace = true +system_accounts.workspace = true +testnet_initial_state.workspace = true +vault_core.workspace = true + [features] default = [] # Runs the sequencer in standalone mode without depending on Bedrock and Indexer services. diff --git a/lez/sequencer/service/protocol/Cargo.toml b/lez/sequencer/service/protocol/Cargo.toml index ced19e755..a6ef0ff09 100644 --- a/lez/sequencer/service/protocol/Cargo.toml +++ b/lez/sequencer/service/protocol/Cargo.toml @@ -13,4 +13,9 @@ lee.workspace = true lee_core.workspace = true hex.workspace = true +serde.workspace = true +thiserror.workspace = true serde_with.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/lez/sequencer/service/protocol/src/lib.rs b/lez/sequencer/service/protocol/src/lib.rs index ce669d312..049858977 100644 --- a/lez/sequencer/service/protocol/src/lib.rs +++ b/lez/sequencer/service/protocol/src/lib.rs @@ -5,7 +5,169 @@ use std::{fmt::Display, str::FromStr}; pub use common::{HashType, block::Block, transaction::LeeTransaction}; pub use lee::{Account, AccountId, ProgramId}; pub use lee_core::{BlockId, Commitment, CommitmentSetDigest, MembershipProof, account::Nonce}; -use serde_with::{DeserializeFromStr, SerializeDisplay}; +use serde::{Deserialize, Serialize}; +use serde_with::{DeserializeFromStr, DisplayFromStr, SerializeDisplay, serde_as}; + +/// One of the two block resources. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CapResource { + ExecutionGas, + StorageGas, +} + +impl Display for CapResource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ExecutionGas => write!(f, "execution gas"), + Self::StorageGas => write!(f, "storage gas"), + } + } +} + +/// Why the sequencer refused a transaction at submission, with the values that decided it. +/// +/// Travels twice in the JSON-RPC error: rendered into `message`, and serialized whole into `data`. +/// A client branches on [`Self::code`] or on the `check` tag; it never has to read the prose. +/// +/// Every `u128` here is carried as a decimal *string*. The `check` tag makes this internally +/// tagged, and serde's internally-tagged deserializer buffers through a `Content` type that has no +/// `u128` variant: a bare `u128` field would serialize and then fail to read back at any value. +/// Encoding them as strings also keeps them exact for a JSON client whose numbers stop being so +/// past 2^53. +#[serde_as] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)] +#[serde(tag = "check", rename_all = "snake_case")] +pub enum AdmissionRejection { + /// A public transaction must serialize to at least one byte. + #[error("public transaction serialization is empty (data_bytes must be >= 1)")] + EmptyDataBytes, + + /// Serialized length past what a whole block may carry. + #[error("data_bytes {data_bytes} exceeds the block storage cap {max}")] + DataBytesExceedsMax { data_bytes: u64, max: u64 }, + + /// Declared execution bound past what a whole block may execute. + #[error("gas_limit {gas_limit} exceeds the block execution cap {max}")] + GasLimitExceedsMax { gas_limit: u64, max: u64 }, + + /// The signed `max_fee` cannot cover the reservation at the head's base fees. Advisory: base + /// fees move every block, so this can go stale in either direction. + #[error( + "signed max_fee {max_fee} is below the fee reserve {fee_reserve} at the current base fees" + )] + MaxFeeBelowReserve { + #[serde_as(as = "DisplayFromStr")] + fee_reserve: u128, + #[serde_as(as = "DisplayFromStr")] + max_fee: u128, + }, + + /// Only the fee witness signs, so including the transaction would burn no nonce and it would + /// stay includable byte-for-byte for ever, draining its payer. + #[error( + "a charged transaction must carry at least one signer signature, so that including it \ + burns a nonce: a fee witness alone consumes no replay protection" + )] + FeeWitnessOnly, + + /// No signature accompanying the transaction authorizes its designated payer. + #[error( + "payer {payer} is not among the transaction's fee-authorized accounts: it must either \ + sign the transaction or accompany it as the fee witness" + )] + UnauthorizedPayer { payer: AccountId }, + + /// The payer's balance at the head is below the reservation. Advisory: balances move with + /// every transaction, this one included if a pending transfer funds it. + #[error( + "payer {payer} holds {balance} but its fee reserve at the current base fees is \ + {fee_reserve}" + )] + PayerCannotFund { + payer: AccountId, + #[serde_as(as = "DisplayFromStr")] + balance: u128, + #[serde_as(as = "DisplayFromStr")] + fee_reserve: u128, + }, + + /// What the transaction declares does not fit an *empty* block, so no block can hold it. + #[error( + "transaction declares {gas_exec} execution gas and {gas_stor} storage gas, over the \ + block's {resource} cap (execution {max_gas_exec}, storage {max_gas_stor}): no block can \ + include it" + )] + ExceedsBlockCap { + resource: CapResource, + gas_exec: u64, + gas_stor: u64, + max_gas_exec: u64, + max_gas_stor: u64, + }, + + /// A static fee-validity rule with no variant of its own above. + /// + /// Nothing the sequencer runs at admission produces this today; it exists so the mapping from + /// `fee_core`'s error enum stays total when a rule is added there. + #[error("transaction is not statically fee-valid: {reason}")] + OtherFeeValidity { reason: String }, +} + +impl AdmissionRejection { + /// The JSON-RPC error code this rejection is returned under: one per check, stable, and the + /// thing a client branches on. + #[must_use] + pub const fn code(&self) -> i32 { + match self { + Self::EmptyDataBytes => -31_980, + Self::DataBytesExceedsMax { .. } => -31_981, + Self::GasLimitExceedsMax { .. } => -31_982, + Self::MaxFeeBelowReserve { .. } => -31_983, + Self::FeeWitnessOnly => -31_984, + Self::UnauthorizedPayer { .. } => -31_985, + Self::PayerCannotFund { .. } => -31_986, + Self::ExceedsBlockCap { .. } => -31_987, + Self::OtherFeeValidity { .. } => -31_988, + } + } +} + +/// What a client needs to price a transaction before it signs it. +/// +/// Everything here is read off the sequencer's head fee state, so it prices the next block this +/// sequencer builds. A transaction's reservation is +/// `gas_limit * base_fee_exec + data_bytes * base_fee_stor + tip`, and it must not exceed the +/// signed `max_fee` at the base fees of the block that includes it — which is why the next-block +/// band matters: a transaction that waits is priced at prices that have moved. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct FeeStateQuote { + /// Execution base fee, atomic units per gas. + pub base_fee_exec: u64, + /// Storage base fee, atomic units per byte. + pub base_fee_stor: u64, + /// The lowest the execution base fee can be next block: one update step at an empty block. + pub next_base_fee_exec_floor: u64, + /// The highest the execution base fee can be next block: one update step at a full block. + pub next_base_fee_exec_ceiling: u64, + /// The lowest the storage base fee can be next block. + pub next_base_fee_stor_floor: u64, + /// The highest the storage base fee can be next block. + pub next_base_fee_stor_ceiling: u64, + /// What a private transaction's fixed gas costs at these base fees: + /// `PRIVATE_VERIFY_GAS * base_fee_exec + PRIVATE_GAS_STOR * base_fee_stor`. Private + /// transactions are not charged today, so nobody is debited this. + /// + /// It prices the capacity one consumes only on the execution side. The storage term assumes + /// the padded wire format that is not in place yet: a private transaction currently counts its + /// real serialized length against the storage cap, so the storage half of this quote is what + /// the re-pin will make true, not what it consumes today. + pub private_fee_quote: u128, + /// Execution gas cap per block; the ceiling on any `gas_limit`. + pub max_gas_exec: u64, + /// Storage bytes cap per block; the ceiling on any transaction's serialized length. + pub max_gas_stor: u64, +} #[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)] pub struct ChannelId(pub [u8; 32]); @@ -26,3 +188,69 @@ impl FromStr for ChannelId { Ok(Self(bytes)) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Every rejection, so the round trip below covers the enum rather than a sample of it. + fn every_rejection() -> Vec { + let payer = AccountId::new([7_u8; 32]); + vec![ + AdmissionRejection::EmptyDataBytes, + AdmissionRejection::DataBytesExceedsMax { + data_bytes: 1_024, + max: 512, + }, + AdmissionRejection::GasLimitExceedsMax { + gas_limit: 9_000, + max: 8_000, + }, + AdmissionRejection::MaxFeeBelowReserve { + fee_reserve: u128::MAX, + max_fee: 1, + }, + AdmissionRejection::FeeWitnessOnly, + AdmissionRejection::UnauthorizedPayer { payer }, + AdmissionRejection::PayerCannotFund { + payer, + balance: 0, + fee_reserve: u128::MAX, + }, + AdmissionRejection::ExceedsBlockCap { + resource: CapResource::StorageGas, + gas_exec: 1, + gas_stor: 2, + max_gas_exec: 3, + max_gas_stor: 4, + }, + AdmissionRejection::OtherFeeValidity { + reason: "some future rule".to_owned(), + }, + ] + } + + /// A rejection travels in the JSON-RPC error's `data`, so a client has to be able to read back + /// what the sequencer wrote. Serializing alone does not prove that: `#[serde(tag = "check")]` + /// makes deserialization buffer through serde's `Content`, which has no `u128` variant, so a + /// raw `u128` field serializes fine and then fails to deserialize at *any* value. The + /// `DisplayFromStr` encoding on those fields is what avoids it — and this covers every variant + /// so a new one carrying a bare `u128` cannot reintroduce it unnoticed. + #[test] + fn every_rejection_round_trips_through_json() { + for rejection in every_rejection() { + let json = serde_json::to_string(&rejection).expect("serializes"); + let back: AdmissionRejection = serde_json::from_str(&json).unwrap_or_else(|err| { + panic!("{rejection:?} failed to deserialize from {json}: {err}") + }); + assert_eq!(back, rejection, "round trip changed the rejection"); + } + } + + /// The tag is the discriminant a client branches on when it does not want the error code. + #[test] + fn a_rejection_carries_its_check_tag() { + let json = serde_json::to_value(AdmissionRejection::FeeWitnessOnly).expect("serializes"); + assert_eq!(json["check"], "fee_witness_only"); + } +} diff --git a/lez/sequencer/service/rpc/src/lib.rs b/lez/sequencer/service/rpc/src/lib.rs index a1d2acfb4..cdc9fdc22 100644 --- a/lez/sequencer/service/rpc/src/lib.rs +++ b/lez/sequencer/service/rpc/src/lib.rs @@ -6,8 +6,8 @@ use jsonrpsee::types::ErrorObjectOwned; #[cfg(feature = "client")] pub use jsonrpsee::{core::ClientError, http_client::HttpClientBuilder as SequencerClientBuilder}; use sequencer_service_protocol::{ - Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, HashType, - LeeTransaction, MembershipProof, Nonce, ProgramId, + Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, FeeStateQuote, + HashType, LeeTransaction, MembershipProof, Nonce, ProgramId, }; #[cfg(all(not(feature = "server"), not(feature = "client")))] @@ -43,6 +43,11 @@ pub trait Rpc { #[method(name = "checkHealth")] async fn check_health(&self) -> Result<(), ErrorObjectOwned>; + /// Current base fees, the band the next block's can land in, and the protocol caps — what a + /// wallet needs to choose `gas_limit` and `max_fee` before it signs. See [`FeeStateQuote`]. + #[method(name = "getFeeState")] + async fn get_fee_state(&self) -> Result; + // TODO: These functions should be removed after wallet starts using indexer // for this type of queries. // diff --git a/lez/sequencer/service/src/fees.rs b/lez/sequencer/service/src/fees.rs new file mode 100644 index 000000000..69b14b5a7 --- /dev/null +++ b/lez/sequencer/service/src/fees.rs @@ -0,0 +1,603 @@ +//! Fee admission and pricing for the RPC ingest path. +//! +//! Admission is **anti-spam, not consensus**: the block transition +//! (`chain_state::check_charged_tx`) and the block builder are what actually enforce these rules. +//! What this adds is a door: a transaction no block can ever include is turned away at submission +//! instead of sitting in the mempool, and a client is told which rule it broke instead of watching +//! its transaction silently never land. The rejection itself is a +//! [`sequencer_service_protocol::AdmissionRejection`], so a client reads the values that decided it +//! out of the error's `data` field rather than out of prose. +//! +//! Two of the checks are advisory by nature. Base fees move every block and balances move every +//! transaction, so `max_fee >= fee_reserve` and "the payer can fund the reserve" are judged against +//! the head state at submission time and can go stale either way afterwards. The rest are static +//! properties of the transaction and cannot. + +use chain_state::charged_fee_view; +use common::transaction::LeeTransaction; +use fee_core::{ + FeeError, FeeState, FeeTxView, InvalidBlockError, PayerId, fee_reserve, + params::{MAX_GAS_EXEC, MAX_GAS_STOR}, + stepped_base_fees, validate_static_tx, +}; +use jsonrpsee::types::ErrorObjectOwned; +use sequencer_core::BlockResource; +use sequencer_service_protocol::{AdmissionRejection, CapResource, FeeStateQuote}; + +/// The JSON-RPC error a rejection is returned as: its own code, the rendered reason, and the +/// rejection itself in `data` for a client that would rather not parse the reason. +#[must_use] +pub fn rejection_error(rejection: &AdmissionRejection) -> ErrorObjectOwned { + ErrorObjectOwned::owned(rejection.code(), rejection.to_string(), Some(rejection)) +} + +/// Screens a submitted transaction against the head state. +/// +/// Fee-exempt classes skip the charged checks — the full vault sweep is exactly the transaction of +/// an account that cannot yet pay. The cap check covers every class that consumes block space, +/// which is all of them *except* system transactions: those are cap-exempt here for the same reason +/// the block transition's `validate_block_storage_cap` skips them. A system-shaped transaction +/// therefore passes both, bounded only by the request's size check (see the note on +/// `common::transaction::is_system_transaction`). +/// +/// # Errors +/// +/// The first check that fails, with what it compared. +pub fn screen(tx: &LeeTransaction, state: &lee::V03State) -> Result<(), AdmissionRejection> { + // Classification decodes the transaction, so it is done once here and threaded onward. + let charged = charged_fee_view(tx, state); + if let Some(view) = charged { + let LeeTransaction::Public(public_tx) = tx else { + unreachable!("only public transactions are charged"); + }; + screen_charged(public_tx, &view, state)?; + } + + // The builder's own pre-screen and this one read the same bound, so nothing admitted here is + // dropped there as unbuildable. + if let Some(bound) = sequencer_core::static_cap_bound(tx, state, charged) + && let Some(resource) = sequencer_core::exceeds_empty_block(bound) + { + return Err(AdmissionRejection::ExceedsBlockCap { + resource: match resource { + BlockResource::ExecutionGas => CapResource::ExecutionGas, + BlockResource::StorageGas => CapResource::StorageGas, + }, + gas_exec: bound.gas_exec, + gas_stor: bound.gas_stor, + max_gas_exec: MAX_GAS_EXEC, + max_gas_stor: MAX_GAS_STOR, + }); + } + + Ok(()) +} + +/// The charged-transaction checks, in the order `chain_state::check_charged_tx` runs them, plus +/// the payer-balance check that only a live head state can answer. +fn screen_charged( + public_tx: &lee::PublicTransaction, + view: &FeeTxView, + state: &lee::V03State, +) -> Result<(), AdmissionRejection> { + let fee_state = state.fee_state(); + + if let Err(err) = validate_static_tx(view, fee_state) { + return Err(static_rejection(err)); + } + + if public_tx + .witness_set() + .signatures_and_public_keys() + .is_empty() + { + return Err(AdmissionRejection::FeeWitnessOnly); + } + + let payer = public_tx.message().payer; + if !lee::is_fee_authorized(public_tx.message(), public_tx.witness_set()) { + return Err(AdmissionRejection::UnauthorizedPayer { payer }); + } + + let fee_reserve = fee_reserve(view, fee_state); + let balance = state.get_account_by_id(payer).balance; + if balance < fee_reserve { + return Err(AdmissionRejection::PayerCannotFund { + payer, + balance, + fee_reserve, + }); + } + + Ok(()) +} + +/// One static fee-validity failure, as the rejection carrying its comparands. +/// +/// `validate_static_tx` produces only the four arms named below; the rest of `fee_core`'s error +/// enum belongs to block-level checks admission never runs, and falls through to the catch-all so +/// the mapping stays total without a wildcard. +fn static_rejection(err: FeeError) -> AdmissionRejection { + let FeeError::InvalidBlock(invalid) = err else { + return AdmissionRejection::OtherFeeValidity { + reason: err.to_string(), + }; + }; + match invalid { + InvalidBlockError::EmptyDataBytes => AdmissionRejection::EmptyDataBytes, + InvalidBlockError::DataBytesExceedsMax { data_bytes, max } => { + AdmissionRejection::DataBytesExceedsMax { data_bytes, max } + } + InvalidBlockError::GasLimitExceedsMax { gas_limit, max } => { + AdmissionRejection::GasLimitExceedsMax { gas_limit, max } + } + InvalidBlockError::FeeReserveExceedsMaxFee { + fee_reserve, + max_fee, + } => AdmissionRejection::MaxFeeBelowReserve { + fee_reserve, + max_fee, + }, + InvalidBlockError::StorageCapExceeded { .. } + | InvalidBlockError::GasCapExceeded { .. } + | InvalidBlockError::GasAccumulationOverflow + | InvalidBlockError::UnauthorizedPayer + | InvalidBlockError::EmptyPublicSignerSet => AdmissionRejection::OtherFeeValidity { + reason: invalid.to_string(), + }, + } +} + +/// Prices the next block off `fee_state`. +/// +/// The next-block figures are a band rather than a single estimate: the block being filled is not +/// observable at query time, so what is quoted is one update step at an empty block and one at a +/// block filled to its caps. Every possible next-block base fee lies between them, which is +/// exactly what a wallet needs to size `max_fee` for a transaction that may wait. Both steps go +/// through the same `fee_core` helper the block transition moves the real fee state with. +#[must_use] +pub fn fee_quote(fee_state: &FeeState) -> FeeStateQuote { + let (exec_floor, stor_floor) = stepped_base_fees(fee_state, 0, 0); + let (exec_ceiling, stor_ceiling) = stepped_base_fees(fee_state, MAX_GAS_EXEC, MAX_GAS_STOR); + + FeeStateQuote { + base_fee_exec: fee_state.base_fee_exec, + base_fee_stor: fee_state.base_fee_stor, + next_base_fee_exec_floor: exec_floor, + next_base_fee_exec_ceiling: exec_ceiling, + next_base_fee_stor_floor: stor_floor, + next_base_fee_stor_ceiling: stor_ceiling, + // A private transaction's gas is protocol constants, so its price depends on nothing but + // the base fees; the payer here is the placeholder `fee_reserve` ignores for that arm. + private_fee_quote: fee_reserve( + &FeeTxView::Private { + payer: PayerId([0_u8; 32]), + }, + fee_state, + ), + max_gas_exec: MAX_GAS_EXEC, + max_gas_stor: MAX_GAS_STOR, + } +} + +#[cfg(test)] +mod tests { + use common::test_utils::{ + TEST_GAS_LIMIT, create_transaction_native_token_transfer, + create_transaction_native_token_transfer_with_fees, test_fee_fields, + }; + use lee::{ + AccountId, FeeFields, PrivateKey, PublicKey, V03State, program_deployment_transaction, + public_transaction::{Message, WitnessSet}, + }; + use testnet_initial_state::{initial_pub_accounts_private_keys, initial_state}; + + use super::*; + + fn key(seed: u8) -> PrivateKey { + PrivateKey::try_new([seed; 32]).expect("valid key") + } + + fn account_of(private_key: &PrivateKey) -> AccountId { + AccountId::from(&PublicKey::new_from_private_key(private_key)) + } + + /// A funded account of the initial state, and the key that signs for it. + fn funded() -> (AccountId, PrivateKey) { + let accounts = initial_pub_accounts_private_keys(); + (accounts[0].account_id, accounts[0].pub_sign_key.clone()) + } + + fn recipient() -> AccountId { + initial_pub_accounts_private_keys()[1].account_id + } + + fn wire_size(tx: &LeeTransaction) -> u64 { + u64::try_from(borsh::object_length(tx).expect("serializes")).expect("fits") + } + + /// What the consensus gate says about the same transaction, for the checks both run. + fn consensus_verdict(tx: &LeeTransaction, state: &V03State) -> Result<(), String> { + let view = charged_fee_view(tx, state).expect("charged"); + let LeeTransaction::Public(public_tx) = tx else { + unreachable!("only public transactions are charged"); + }; + chain_state::check_charged_tx(public_tx, &view, state.fee_state()) + } + + #[test] + fn a_funded_transfer_is_admitted() { + let state = initial_state(); + let (from, sign_key) = funded(); + let tx = create_transaction_native_token_transfer(from, 0, recipient(), 10, &sign_key); + + screen(&tx, &state).expect("a well-formed, funded transfer is admitted"); + // Admission must be at least as strict as the gate the builder and the block transition + // run, so nothing it admits is unbuildable. + consensus_verdict(&tx, &state).expect("and the consensus gate agrees"); + } + + #[test] + fn a_gas_limit_beyond_the_block_cap_is_rejected() { + let state = initial_state(); + let (from, sign_key) = funded(); + let tx = create_transaction_native_token_transfer_with_fees( + from, + 0, + recipient(), + 10, + &sign_key, + FeeFields::new(from, MAX_GAS_EXEC + 1, 0, u128::MAX), + ); + + let err = screen(&tx, &state).expect_err("no block can execute that much gas"); + assert!( + matches!( + err, + AdmissionRejection::GasLimitExceedsMax { + gas_limit, + max: MAX_GAS_EXEC, + } if gas_limit == MAX_GAS_EXEC + 1, + ), + "expected the gas-limit bound to fire, got: {err}", + ); + assert!(consensus_verdict(&tx, &state).is_err()); + } + + #[test] + fn a_max_fee_below_the_reserve_is_rejected() { + let state = initial_state(); + let (from, sign_key) = funded(); + let tx = create_transaction_native_token_transfer_with_fees( + from, + 0, + recipient(), + 10, + &sign_key, + FeeFields::new(from, TEST_GAS_LIMIT, 7, 1), + ); + + let fee_state = state.fee_state(); + let expected = u128::from(TEST_GAS_LIMIT) * u128::from(fee_state.base_fee_exec) + + u128::from(wire_size(&tx)) * u128::from(fee_state.base_fee_stor) + + 7; + + let err = screen(&tx, &state).expect_err("a max_fee of 1 covers nothing"); + assert!( + matches!( + err, + AdmissionRejection::MaxFeeBelowReserve { + fee_reserve, + max_fee: 1, + } if fee_reserve == expected, + ), + "expected the reserve {expected} against a max_fee of 1, got: {err}", + ); + assert!(consensus_verdict(&tx, &state).is_err()); + } + + /// The payer is authorized (it signs as the fee witness) but holds nothing, so the reservation + /// the next block would take cannot succeed. + #[test] + fn a_payer_that_cannot_fund_the_reserve_is_rejected() { + let state = initial_state(); + let (from, sign_key) = funded(); + let sponsor = key(9); + let message = Message::try_new( + programs::authenticated_transfer().id(), + vec![from, recipient()], + vec![0_u128.into()], + authenticated_transfer_core::Instruction::Transfer { amount: 10 }, + FeeFields::new(account_of(&sponsor), TEST_GAS_LIMIT, 0, u128::MAX), + ) + .expect("message builds"); + let witness_set = + WitnessSet::for_message(&message, &[&sign_key]).with_fee_signer(&message, &sponsor); + let tx = LeeTransaction::Public(lee::PublicTransaction::new(message, witness_set)); + + let err = screen(&tx, &state).expect_err("a sponsor with no balance cannot fund it"); + assert!( + matches!( + err, + AdmissionRejection::PayerCannotFund { + payer, + balance: 0, + .. + } if payer == account_of(&sponsor), + ), + "expected an unfundable payer, got: {err}", + ); + // Balance is not the consensus gate's business: this one is admission-only, and the block + // transition rejects it later at the reservation itself. + consensus_verdict(&tx, &state).expect("the static gate has nothing against it"); + } + + /// Fee-witness-only: nothing but the payer's fee authorization accompanies the transaction, so + /// including it would burn no nonce and it would stay includable for ever. + #[test] + fn a_fee_witness_only_transaction_is_rejected() { + let state = initial_state(); + let (from, sign_key) = funded(); + let message = Message::try_new( + programs::authenticated_transfer().id(), + vec![from, recipient()], + vec![0_u128.into()], + authenticated_transfer_core::Instruction::Transfer { amount: 10 }, + test_fee_fields(from), + ) + .expect("message builds"); + // No signer signatures at all: the payer's fee witness is the only one. + let witness_set = WitnessSet::from_raw_parts(vec![]).with_fee_signer(&message, &sign_key); + let tx = LeeTransaction::Public(lee::PublicTransaction::new(message, witness_set)); + + let err = screen(&tx, &state).expect_err("a fee witness alone authorizes no state access"); + assert!( + matches!(err, AdmissionRejection::FeeWitnessOnly), + "expected the fee-witness-only rejection, got: {err}", + ); + assert!( + consensus_verdict(&tx, &state).is_err(), + "and the consensus gate rejects it too, only later", + ); + } + + #[test] + fn a_payer_nothing_authorizes_is_rejected() { + let state = initial_state(); + let (from, sign_key) = funded(); + let stranger = account_of(&key(9)); + let tx = create_transaction_native_token_transfer_with_fees( + from, + 0, + recipient(), + 10, + &sign_key, + FeeFields::new(stranger, TEST_GAS_LIMIT, 0, u128::MAX), + ); + + let err = screen(&tx, &state).expect_err("nobody authorized the stranger to pay"); + assert!( + matches!(err, AdmissionRejection::UnauthorizedPayer { payer } if payer == stranger), + "expected an unauthorized payer, got: {err}", + ); + assert!(consensus_verdict(&tx, &state).is_err()); + } + + /// The bootstrap case: a full vault sweep is fee-exempt, so none of the charged checks may run + /// against it — its whole point is that the sweeper holds nothing yet. + #[test] + fn a_full_vault_sweep_by_an_unfunded_account_is_admitted() { + let mut state = initial_state(); + let sweeper_key = key(9); + let sweeper = account_of(&sweeper_key); + let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), sweeper); + state.force_insert_account( + vault_id, + lee::Account { + program_owner: programs::vault().id(), + balance: 500_000_000, + ..lee::Account::default() + }, + ); + + let message = Message::try_new( + programs::vault().id(), + vec![sweeper, vault_id], + vec![0_u128.into()], + vault_core::Instruction::Claim { + amount: 500_000_000, + }, + // A sweep is exempt whatever it declares, and a wallet with nothing to pay with signs + // a zero `max_fee`: neither the reserve check nor the balance check may see this. + FeeFields::new(sweeper, TEST_GAS_LIMIT, 0, 0), + ) + .expect("message builds"); + let witness_set = WitnessSet::for_message(&message, &[&sweeper_key]); + let tx = LeeTransaction::Public(lee::PublicTransaction::new(message, witness_set)); + + assert!( + charged_fee_view(&tx, &state).is_none(), + "a full sweep is fee-exempt", + ); + screen(&tx, &state).expect("so admission must let it through"); + } + + /// A private transaction is uncharged but capped. Its *execution* gas is the protocol constant + /// `PRIVATE_VERIFY_GAS`, so whether any private transaction clears that cap is a property of + /// the constant rather than of the transaction; its storage gas is still its real serialized + /// length (`chain_state::classify`, TBA(INCREMENTIAL)), so the storage assertion below guards + /// the re-pin to `PRIVATE_GAS_STOR` rather than pinning what runs today. Either constant + /// re-pinned past its cap would make every private transaction permanently inadmissible, and + /// nothing else in the tree would notice. + #[test] + fn a_private_transaction_is_admitted_and_its_constants_fit_a_block() { + use lee::privacy_preserving_transaction::{ + Message as PrivateMessage, PrivacyPreservingTransaction, + WitnessSet as PrivateWitnessSet, circuit::Proof, + }; + use lee_core::program::{BlockValidityWindow, TimestampValidityWindow}; + + const { + assert!( + fee_core::params::PRIVATE_VERIFY_GAS <= MAX_GAS_EXEC, + "a private transaction's constant execution gas must fit a block", + ); + assert!( + fee_core::params::PRIVATE_GAS_STOR <= MAX_GAS_STOR, + "and so must its constant storage gas", + ); + } + + let state = initial_state(); + let tx = LeeTransaction::PrivacyPreserving(PrivacyPreservingTransaction::new( + PrivateMessage { + public_actions: vec![], + nonces: vec![], + private_actions: vec![], + block_validity_window: BlockValidityWindow::new_unbounded(), + timestamp_validity_window: TimestampValidityWindow::new_unbounded(), + }, + PrivateWitnessSet::from_raw_parts(vec![], Proof::from_inner(vec![])), + )); + + assert!( + charged_fee_view(&tx, &state).is_none(), + "private transactions are uncharged today", + ); + screen(&tx, &state).expect("and admissible: no fee field of theirs is screened"); + } + + /// System transactions are fee-exempt *and* cap-exempt, here as in the block transition + /// (`validate_block_storage_cap` skips them too), so admission has nothing to compare and lets + /// one through unscreened — the arm neither check reaches. + /// + /// Built the way a *user* would: the shape is craftable and the classification is structural, + /// so this also pins the accepted consequence documented on `is_system_transaction` — an + /// unsigned deposit-shaped transaction rides free, bounded only by the ingest size check, and + /// is then rejected by the bridge program for not matching a real L1 event. + #[test] + fn a_system_shaped_transaction_is_admitted_unscreened() { + let state = initial_state(); + let recipient = account_of(&key(9)); + let message = Message::try_new( + programs::bridge().id(), + vec![ + system_accounts::bridge_account_id(), + vault_core::compute_vault_account_id(programs::vault().id(), recipient), + bridge_core::deposit_receipt_account_id(programs::bridge().id(), [7_u8; 32]), + ], + vec![], + bridge_core::Instruction::Deposit { + l1_deposit_op_id: [7_u8; 32], + vault_program_id: programs::vault().id(), + recipient_id: recipient, + amount: 1_000, + }, + FeeFields::ZERO, + ) + .expect("message builds"); + // Unsigned: what makes it system-shaped, and what a sequencer injection looks like. + let tx = LeeTransaction::Public(lee::PublicTransaction::new( + message, + WitnessSet::from_raw_parts(vec![]), + )); + + assert!(common::transaction::is_system_transaction(&tx)); + assert!(charged_fee_view(&tx, &state).is_none()); + assert!( + sequencer_core::static_cap_bound(&tx, &state, None).is_none(), + "cap-exempt, so the cap check has nothing to compare either", + ); + screen(&tx, &state).expect("nothing to screen"); + } + + /// Uncharged is not uncapped: a deployment that no block could carry is turned away at the + /// door, where before it would have sat in the mempool for ever. + #[test] + fn an_uncharged_transaction_over_the_storage_cap_is_rejected() { + let state = initial_state(); + let deployer = key(9); + let message = program_deployment_transaction::Message::new( + vec![0_u8; usize::try_from(MAX_GAS_STOR).expect("fits") + 1], + FeeFields::new(account_of(&deployer), 0, 0, 0), + ); + let witness_set = WitnessSet::for_message(&message, &[&deployer]); + let tx = LeeTransaction::ProgramDeployment(lee::ProgramDeploymentTransaction::new( + message, + witness_set, + )); + + assert!( + charged_fee_view(&tx, &state).is_none(), + "deployments are not charged today", + ); + let err = screen(&tx, &state).expect_err("but they are capped"); + assert!( + matches!( + err, + AdmissionRejection::ExceedsBlockCap { + resource: CapResource::StorageGas, + .. + } + ), + "expected the storage cap to be the one that fired, got: {err}", + ); + } + + /// SPECS §Overview worked example: at the genesis base fees of 8/8 a private transaction's + /// fixed gas prices out at 5,070,616, and the next block's fees can only stay at the minimum + /// or rise by the guaranteed +1 step. + #[test] + fn the_quote_prices_the_head_fee_state() { + let state = initial_state(); + let quote = fee_quote(state.fee_state()); + + assert_eq!(quote.base_fee_exec, 8); + assert_eq!(quote.base_fee_stor, 8); + assert_eq!(quote.next_base_fee_exec_floor, 8); + assert_eq!(quote.next_base_fee_exec_ceiling, 9); + assert_eq!(quote.next_base_fee_stor_floor, 8); + assert_eq!(quote.next_base_fee_stor_ceiling, 9); + assert_eq!(quote.private_fee_quote, 5_070_616); + assert_eq!(quote.max_gas_exec, MAX_GAS_EXEC); + assert_eq!(quote.max_gas_stor, MAX_GAS_STOR); + } + + /// The quote is what a wallet prices `max_fee` off, so the reserve it computes from those two + /// numbers must be the one admission compares against. + #[test] + fn a_reserve_computed_from_the_quote_matches_the_one_admission_uses() { + let state = initial_state(); + let (from, sign_key) = funded(); + let tx = create_transaction_native_token_transfer_with_fees( + from, + 0, + recipient(), + 10, + &sign_key, + FeeFields::new(from, TEST_GAS_LIMIT, 3, u128::MAX), + ); + let quote = fee_quote(state.fee_state()); + + let by_hand = u128::from(TEST_GAS_LIMIT) * u128::from(quote.base_fee_exec) + + u128::from(wire_size(&tx)) * u128::from(quote.base_fee_stor) + + 3; + let view = charged_fee_view(&tx, &state).expect("charged"); + assert_eq!(fee_reserve(&view, state.fee_state()), by_hand); + + // One unit of headroom below it is exactly what admission rejects. + let too_tight = create_transaction_native_token_transfer_with_fees( + from, + 0, + recipient(), + 10, + &sign_key, + FeeFields::new(from, TEST_GAS_LIMIT, 3, by_hand - 1), + ); + assert!(matches!( + screen(&too_tight, &state).expect_err("one unit short"), + AdmissionRejection::MaxFeeBelowReserve { .. } + )); + screen(&tx, &state).expect("and the same transaction with room to spare is admitted"); + } +} diff --git a/lez/sequencer/service/src/lib.rs b/lez/sequencer/service/src/lib.rs index 683b8ad4c..a98ea7b82 100644 --- a/lez/sequencer/service/src/lib.rs +++ b/lez/sequencer/service/src/lib.rs @@ -21,6 +21,7 @@ use sequencer_service_rpc::RpcServer as _; use tokio::{sync::Mutex, task::JoinHandle}; use tokio_util::sync::CancellationToken; +pub mod fees; pub mod service; const REQUEST_BODY_MAX_SIZE: ByteSize = ByteSize::mib(10); diff --git a/lez/sequencer/service/src/service.rs b/lez/sequencer/service/src/service.rs index e55735c07..4545d8a4e 100644 --- a/lez/sequencer/service/src/service.rs +++ b/lez/sequencer/service/src/service.rs @@ -12,11 +12,13 @@ use sequencer_core::{ DbError, SequencerCore, TransactionOrigin, block_publisher::BlockPublisherTrait, }; use sequencer_service_protocol::{ - Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, HashType, - MembershipProof, Nonce, ProgramId, + Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, FeeStateQuote, + HashType, MembershipProof, Nonce, ProgramId, }; use tokio::sync::Mutex; +use crate::fees; + const NOT_FOUND_ERROR_CODE: i32 = -31999; pub struct SequencerService { @@ -48,55 +50,15 @@ impl sequencer_service_rpc::Rpc let tx_hash = tx.hash(); - let res = async move { - // Reserve ~200 bytes for block header overhead - const BLOCK_HEADER_OVERHEAD: u64 = 200; - - let encoded_tx = - borsh::to_vec(&tx).expect("Transaction borsh serialization should not fail"); - let tx_size = - u64::try_from(encoded_tx.len()).expect("Transaction size should fit in u64"); - - let max_tx_size = self.max_block_size.saturating_sub(BLOCK_HEADER_OVERHEAD); - - if tx_size > max_tx_size { - return Err(ErrorObjectOwned::owned( - ErrorCode::InvalidParams.code(), - format!("Transaction too large: size {tx_size}, max {max_tx_size}"), - None::<()>, - )); - } - - let authenticated_tx = tx - .transaction_stateless_check() - .inspect_err(|err| warn!("Error at pre_check {err:#?}")) - .map_err(|err| { - ErrorObjectOwned::owned( - ErrorCode::InvalidParams.code(), - format!("{err:?}"), - None::<()>, - ) - })?; - - // Sequencer-only programs (the cross-zone inbox) are injected by the - // watcher; a user must not invoke them top-level, or anyone could forge - // an inbound cross-zone delivery. Chained user calls are already rejected - // by the inbox guest's caller-is-none assertion. - if let LeeTransaction::Public(public_tx) = &authenticated_tx - && sequencer_core::is_sequencer_only_program(public_tx.message().program_id) - { - return Err(ErrorObjectOwned::owned( - ErrorCode::InvalidParams.code(), - "Program is sequencer-only and cannot be invoked by a user transaction" - .to_owned(), - None::<()>, - )); - } - - Ok(authenticated_tx) + let admitted = { + // FIXME(fees-edges): `main_loop` holds this lock across `produce_new_block`, so ingest + // stalls for a whole build; `with_state` needs only `&self`, so handing the service a + // direct state handle would drop the outer lock. + let sequencer = self.sequencer.lock().await; + sequencer.with_state(|state| admit(tx, self.max_block_size, state)) }; - let authenticated_tx = res.await.inspect_err(|err| { + let authenticated_tx = admitted.inspect_err(|err| { sequencer_service_metrics::increment_before_mempool_failed_transactions_total(); error!("Transaction failed before reaching mempool: {err:#?}"); })?; @@ -113,6 +75,11 @@ impl sequencer_service_rpc::Rpc Ok(()) } + async fn get_fee_state(&self) -> Result { + let sequencer = self.sequencer.lock().await; + Ok(sequencer.with_state(|state| fees::fee_quote(state.fee_state()))) + } + async fn get_block(&self, block_id: BlockId) -> Result, ErrorObjectOwned> { let sequencer = self.sequencer.lock().await; sequencer @@ -219,6 +186,131 @@ impl sequencer_service_rpc::Rpc } } +/// Everything a submitted transaction must clear before it reaches the mempool: it has to fit a +/// block on its own, be well-formed and signed, not impersonate a sequencer injection, and pass +/// fee admission against the head `state`. +/// +/// One gate rather than a sequence of them at the call site, so nothing can reach +/// [`sequencer_service_rpc::RpcServer::send_transaction`]'s mempool push having passed only part +/// of it. +fn admit( + tx: LeeTransaction, + max_block_size: u64, + state: &lee::V03State, +) -> Result { + // Reserve ~200 bytes for block header overhead + const BLOCK_HEADER_OVERHEAD: u64 = 200; + + let encoded_tx = borsh::to_vec(&tx).expect("Transaction borsh serialization should not fail"); + let tx_size = u64::try_from(encoded_tx.len()).expect("Transaction size should fit in u64"); + + let max_tx_size = max_block_size.saturating_sub(BLOCK_HEADER_OVERHEAD); + + if tx_size > max_tx_size { + return Err(ErrorObjectOwned::owned( + ErrorCode::InvalidParams.code(), + format!("Transaction too large: size {tx_size}, max {max_tx_size}"), + None::<()>, + )); + } + + let authenticated_tx = tx + .transaction_stateless_check() + .inspect_err(|err| warn!("Error at pre_check {err:#?}")) + .map_err(|err| { + ErrorObjectOwned::owned( + ErrorCode::InvalidParams.code(), + format!("{err:?}"), + None::<()>, + ) + })?; + + // Sequencer-only programs (the cross-zone inbox) are injected by the + // watcher; a user must not invoke them top-level, or anyone could forge + // an inbound cross-zone delivery. Chained user calls are already rejected + // by the inbox guest's caller-is-none assertion. + if let LeeTransaction::Public(public_tx) = &authenticated_tx + && sequencer_core::is_sequencer_only_program(public_tx.message().program_id) + { + return Err(ErrorObjectOwned::owned( + ErrorCode::InvalidParams.code(), + "Program is sequencer-only and cannot be invoked by a user transaction".to_owned(), + None::<()>, + )); + } + + // Fee admission: what no block of this chain would include, or what the payer visibly cannot + // afford right now (see `fees::screen`). + fees::screen(&authenticated_tx, state) + .map_err(|rejection| fees::rejection_error(&rejection))?; + + Ok(authenticated_tx) +} + fn internal_error(err: &DbError) -> ErrorObjectOwned { ErrorObjectOwned::owned(ErrorCode::InternalError.code(), err.to_string(), None::<()>) } + +/// The ingest path, exercised where it is decided: `send_transaction` does nothing but call +/// [`admit`] and push what it returns, so a fee-invalid transaction being refused here is the same +/// refusal a client sees. +#[cfg(test)] +mod tests { + use common::test_utils::create_transaction_native_token_transfer_with_fees; + use lee::FeeFields; + use sequencer_service_protocol::AdmissionRejection; + use testnet_initial_state::{initial_pub_accounts_private_keys, initial_state}; + + use super::*; + + /// Generous enough that the size check never decides these tests. + const MAX_BLOCK_SIZE: u64 = 0x0010_0000; + + #[test] + fn the_ingest_path_admits_a_funded_transfer() { + let state = initial_state(); + let accounts = initial_pub_accounts_private_keys(); + let tx = common::test_utils::create_transaction_native_token_transfer( + accounts[0].account_id, + 0, + accounts[1].account_id, + 10, + &accounts[0].pub_sign_key, + ); + + admit(tx, MAX_BLOCK_SIZE, &state).expect("a well-formed, funded transfer is admitted"); + } + + /// The fee screen is part of the ingest path, not something only `fees::screen`'s own tests + /// reach: a transaction that is perfectly well-formed and correctly signed, and fails nothing + /// but fee admission, must not reach the mempool. + #[test] + fn the_ingest_path_rejects_a_fee_invalid_transaction() { + let state = initial_state(); + let accounts = initial_pub_accounts_private_keys(); + let payer = accounts[0].account_id; + let tx = create_transaction_native_token_transfer_with_fees( + payer, + 0, + accounts[1].account_id, + 10, + &accounts[0].pub_sign_key, + FeeFields::new(payer, common::test_utils::TEST_GAS_LIMIT, 0, 1), + ); + + let err = admit(tx, MAX_BLOCK_SIZE, &state).expect_err("its max_fee covers nothing"); + assert_eq!( + err.code(), + AdmissionRejection::MaxFeeBelowReserve { + fee_reserve: 0, + max_fee: 0, + } + .code(), + "the rejection must arrive under the max_fee check's own code", + ); + assert!( + err.data().is_some(), + "and carry the structured rejection in `data`", + ); + } +}