Files
logos-execution-zone/FEES.md
T
erhant e04a238567 feat(sequencer)!: screen transactions for fee validity at RPC ingest
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.
2026-08-13 13:58:31 +03:00

5.6 KiB

Fees

A public transaction names a payer account and signs its gas_limit, tip, and max_fee alongside the rest of the message. On inclusion it pays gas_exec * base_fee_exec + gas_stor * base_fee_stor + tip, where gas_exec is the metered execution cycle count (halted at gas_limit) and gas_stor is the transaction's serialized length in bytes. Each base fee moves by at most ±12.5% per block toward a per-block target, so prices react to congestion but never jump.

What is charged today, and what merely consumes block capacity:

Transaction Charged Counted against the block caps
Public, user-submitted yes yes
System: sequencer injections (bridge-deposit mints, cross-zone dispatch) and every genesis transaction no no
Private no yes — constant execution gas, real serialized bytes
Program deployment no yes, storage only (no execution gas)
Full vault sweep (a claim of the vault's entire balance) no yes, metered like a public transaction

The three uncharged rows — private, deployment, and full sweep — are deliberate and temporary; each is marked in the source with a TBA(...) note naming what has to be decided before it goes away. TBA(INCREMENTIAL)/TBA(Q3): the private fee mechanism, who a fully-shielded transaction's payer is, and the constant-size wire format that will replace a private transaction's real byte count with a fixed one. TBA(deployment-replay): deployments carry no nonce, so a charged-but-failed deployment would stay re-includable and drain its payer. TBA(fee-bootstrap): the sweep exemption below, which tokenomics may replace with a claim that pays out of the funds it sweeps.

Bootstrapping an account. An account funded at genesis or bridged in from the L1 starts with its balance in a vault, and holds nothing itself — so it cannot pay for the transaction that funds it. (Accounts seeded directly into the initial state, as lez/testnet_initial_state does, are already funded and skip this.) The way out is a full sweep: a vault claim for exactly the vault's whole balance is fee-exempt. A partial claim is charged like any other public transaction and will normally fail its reservation, so sweep first and split afterwards. just wallet-import-test-accounts does this for the debug accounts.

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.