diff --git a/Cargo.lock b/Cargo.lock index 3799c8dda..b6249622b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1471,6 +1471,7 @@ dependencies = [ "logos-blockchain-zone-sdk", "serde", "serde_json", + "system_accounts", "testnet_initial_state", "thiserror 2.0.18", "tokio", @@ -3018,6 +3019,7 @@ dependencies = [ name = "fee_core" version = "0.1.0" dependencies = [ + "borsh", "lee_core", "serde", ] @@ -10649,6 +10651,7 @@ dependencies = [ name = "testnet_initial_state" version = "0.1.0" dependencies = [ + "fee_core", "key_protocol", "lee", "lee_core", diff --git a/Cargo.toml b/Cargo.toml index 5a842f342..b67450873 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ members = [ "lez/programs/clock", "lez/programs/faucet", "lez/programs/fee", + "lez/programs/fee/core", "lez/programs/pinata", "lez/programs/pinata_token", "lez/programs/token", diff --git a/artifacts/lez/programs/amm.bin b/artifacts/lez/programs/amm.bin index 088b1f282..c324fc2a2 100644 Binary files a/artifacts/lez/programs/amm.bin and b/artifacts/lez/programs/amm.bin differ diff --git a/artifacts/lez/programs/associated_token_account.bin b/artifacts/lez/programs/associated_token_account.bin index 754a73fb4..82471ef2f 100644 Binary files a/artifacts/lez/programs/associated_token_account.bin and b/artifacts/lez/programs/associated_token_account.bin differ diff --git a/artifacts/lez/programs/authenticated_transfer.bin b/artifacts/lez/programs/authenticated_transfer.bin index ae572fcb0..27d416ea8 100644 Binary files a/artifacts/lez/programs/authenticated_transfer.bin and b/artifacts/lez/programs/authenticated_transfer.bin differ diff --git a/artifacts/lez/programs/bridge.bin b/artifacts/lez/programs/bridge.bin index b0dc4ff6c..89384c955 100644 Binary files a/artifacts/lez/programs/bridge.bin and b/artifacts/lez/programs/bridge.bin differ diff --git a/artifacts/lez/programs/bridge_lock.bin b/artifacts/lez/programs/bridge_lock.bin index b12252226..15b34410b 100644 Binary files a/artifacts/lez/programs/bridge_lock.bin and b/artifacts/lez/programs/bridge_lock.bin differ diff --git a/artifacts/lez/programs/clock.bin b/artifacts/lez/programs/clock.bin index cedf2f819..db41cce0a 100644 Binary files a/artifacts/lez/programs/clock.bin and b/artifacts/lez/programs/clock.bin differ diff --git a/artifacts/lez/programs/cross_zone_inbox.bin b/artifacts/lez/programs/cross_zone_inbox.bin index 9eca4e316..aa90f4ed2 100644 Binary files a/artifacts/lez/programs/cross_zone_inbox.bin and b/artifacts/lez/programs/cross_zone_inbox.bin differ diff --git a/artifacts/lez/programs/cross_zone_outbox.bin b/artifacts/lez/programs/cross_zone_outbox.bin index ef9e18b55..811056fba 100644 Binary files a/artifacts/lez/programs/cross_zone_outbox.bin and b/artifacts/lez/programs/cross_zone_outbox.bin differ diff --git a/artifacts/lez/programs/faucet.bin b/artifacts/lez/programs/faucet.bin index 24236e925..e33b7f4ce 100644 Binary files a/artifacts/lez/programs/faucet.bin and b/artifacts/lez/programs/faucet.bin differ diff --git a/artifacts/lez/programs/fee.bin b/artifacts/lez/programs/fee.bin index 0b82a2847..a799abcc4 100644 Binary files a/artifacts/lez/programs/fee.bin and b/artifacts/lez/programs/fee.bin differ diff --git a/artifacts/lez/programs/pinata.bin b/artifacts/lez/programs/pinata.bin index 54b39e5da..83be88bc4 100644 Binary files a/artifacts/lez/programs/pinata.bin and b/artifacts/lez/programs/pinata.bin differ diff --git a/artifacts/lez/programs/pinata_token.bin b/artifacts/lez/programs/pinata_token.bin index 45afc1be7..6c2c5df1b 100644 Binary files a/artifacts/lez/programs/pinata_token.bin and b/artifacts/lez/programs/pinata_token.bin differ diff --git a/artifacts/lez/programs/ping_receiver.bin b/artifacts/lez/programs/ping_receiver.bin index 710eeeb8a..26d0800b1 100644 Binary files a/artifacts/lez/programs/ping_receiver.bin and b/artifacts/lez/programs/ping_receiver.bin differ diff --git a/artifacts/lez/programs/ping_sender.bin b/artifacts/lez/programs/ping_sender.bin index 3a3e40c90..3d482a473 100644 Binary files a/artifacts/lez/programs/ping_sender.bin and b/artifacts/lez/programs/ping_sender.bin differ diff --git a/artifacts/lez/programs/token.bin b/artifacts/lez/programs/token.bin index 47bc6ef1f..17f80037a 100644 Binary files a/artifacts/lez/programs/token.bin and b/artifacts/lez/programs/token.bin differ diff --git a/artifacts/lez/programs/vault.bin b/artifacts/lez/programs/vault.bin index 4a7b2a11e..564ee4914 100644 Binary files a/artifacts/lez/programs/vault.bin and b/artifacts/lez/programs/vault.bin differ diff --git a/artifacts/lez/programs/wrapped_token.bin b/artifacts/lez/programs/wrapped_token.bin index 4b19212a0..c8c0a7e14 100644 Binary files a/artifacts/lez/programs/wrapped_token.bin and b/artifacts/lez/programs/wrapped_token.bin differ diff --git a/lez/chain_state/Cargo.toml b/lez/chain_state/Cargo.toml index ec7c6a45f..045bba053 100644 --- a/lez/chain_state/Cargo.toml +++ b/lez/chain_state/Cargo.toml @@ -25,4 +25,5 @@ tokio.workspace = true [dev-dependencies] testnet_initial_state.workspace = true +system_accounts.workspace = true serde_json.workspace = true diff --git a/lez/chain_state/src/apply.rs b/lez/chain_state/src/apply.rs index e66edf278..f49cfc3dd 100644 --- a/lez/chain_state/src/apply.rs +++ b/lez/chain_state/src/apply.rs @@ -285,6 +285,44 @@ mod tests { assert!(matches!(err, BlockIngestError::EmptyBlock)); } + #[test] + fn fee_state_advances_with_the_chain() { + let mut state = initial_state(); + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + let mut tip = tip_of(&genesis); + for id in 2..=5_u64 { + let block = produce_dummy_block(id, Some(tip.hash), vec![]); + apply_block(Some(&tip), &block, &mut state).expect("block applies"); + tip = tip_of(&block); + } + + let fee_state = fee_core::state::FeeState::from_bytes( + &state + .get_account_by_id(system_accounts::fee_state_account_id()) + .data + .into_inner(), + ); + // Five blocks applied: height tracks the chain; zero load holds the floor. + assert_eq!(fee_state.height, 5); + assert_eq!(fee_state.base_fee_exec, fee_core::market::BASE_FEE_EXEC_MIN); + assert_eq!(fee_state.base_fee_stor, fee_core::market::BASE_FEE_STOR_MIN); + assert_eq!(fee_state.payout_carry, 0); + // Escrow and inbox hold no balance. + assert_eq!( + state + .get_account_by_id(system_accounts::fee_escrow_account_id()) + .balance, + 0 + ); + assert_eq!( + state + .get_account_by_id(system_accounts::fee_inbox_account_id()) + .balance, + 0 + ); + } + #[test] fn missing_fee_tx_is_invalid_fee() { let mut state = initial_state(); diff --git a/lez/programs/fee/core/Cargo.toml b/lez/programs/fee/core/Cargo.toml index 03887a654..7fb18221b 100644 --- a/lez/programs/fee/core/Cargo.toml +++ b/lez/programs/fee/core/Cargo.toml @@ -8,5 +8,6 @@ license = { workspace = true } workspace = true [dependencies] +borsh.workspace = true lee_core.workspace = true serde.workspace = true diff --git a/lez/programs/fee/core/src/lib.rs b/lez/programs/fee/core/src/lib.rs index 031a903fb..246eacdb1 100644 --- a/lez/programs/fee/core/src/lib.rs +++ b/lez/programs/fee/core/src/lib.rs @@ -6,6 +6,9 @@ use lee_core::{ }; use serde::{Deserialize, Serialize}; +pub mod market; +pub mod state; + const FEE_STATE_SEED: [u8; 32] = *b"/LEZ/v0.3/FeeSeed/State/0000000/"; const FEE_ESCROW_SEED: [u8; 32] = *b"/LEZ/v0.3/FeeSeed/Escrow/000000/"; const FEE_INBOX_SEED: [u8; 32] = *b"/LEZ/v0.3/FeeSeed/Inbox/0000000/"; diff --git a/lez/programs/fee/core/src/market.rs b/lez/programs/fee/core/src/market.rs new file mode 100644 index 000000000..09980f633 --- /dev/null +++ b/lez/programs/fee/core/src/market.rs @@ -0,0 +1,206 @@ +//! The dual fee market: protocol constants and the base-fee controller. +//! +//! All values are protocol constants; changing any is a protocol-version +//! change. + +#![expect( + clippy::arithmetic_side_effects, + clippy::integer_division, + clippy::integer_division_remainder_used, + reason = "spec-mandated integer math: products are widened to u128 and subtractions are \ + guarded by the enclosing comparison" +)] + +use core::cmp::Ordering; + +pub const TARGET_GAS_EXEC: u64 = 5_000_000; +pub const MAX_GAS_EXEC: u64 = 10_000_000; +pub const D_EXEC: u64 = 8; +pub const BASE_FEE_EXEC_MIN: u64 = 8; +pub const BASE_FEE_EXEC_MAX: u64 = u64::MAX / MAX_GAS_EXEC; + +pub const TARGET_GAS_STOR: u64 = 500_000; +pub const MAX_GAS_STOR: u64 = 1_000_000; +pub const D_STOR: u64 = 8; +pub const BASE_FEE_STOR_MIN: u64 = 8; +pub const BASE_FEE_STOR_MAX: u64 = u64::MAX / MAX_GAS_STOR; + +pub const SMOOTHING_WINDOW: usize = 50; + +// Genesis validation: the ±12.5% bound and elasticity framing assume +// MAX = 2·TARGET for both resources. +const _: () = assert!(MAX_GAS_EXEC == 2 * TARGET_GAS_EXEC); +const _: () = assert!(MAX_GAS_STOR == 2 * TARGET_GAS_STOR); + +/// One base-fee update. The deviation clamp bounds the move to `max(1, b / d)`; +/// products are widened to 128 bits so the cap price cannot overflow; the +/// result saturates into `[lo, hi]`. +#[must_use] +pub fn next_base_fee(b: u64, g: u64, target: u64, d: u64, lo: u64, hi: u64) -> u64 { + match g.cmp(&target) { + Ordering::Greater => { + let deviation = (g - target).min(target); + let delta = ((u128::from(b) * u128::from(deviation)) + / (u128::from(target) * u128::from(d))) + .max(1); + let delta = u64::try_from(delta).expect("delta is at most b/d, which fits u64"); + hi.min(b.saturating_add(delta)) + } + Ordering::Less => { + let deviation = (target - g).min(target); + let delta = + (u128::from(b) * u128::from(deviation)) / (u128::from(target) * u128::from(d)); + let delta = u64::try_from(delta).expect("delta is at most b/d, which fits u64"); + lo.max(b.saturating_sub(delta)) + } + Ordering::Equal => b, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constants_satisfy_genesis_validation() { + // MAX_GAS_r = 2·TARGET_GAS_r for both resources. + assert_eq!(MAX_GAS_EXEC, 2 * TARGET_GAS_EXEC); + assert_eq!(MAX_GAS_STOR, 2 * TARGET_GAS_STOR); + // MAX_GAS_r · BASE_FEE_r_MAX fits u64 for both resources. + assert_eq!(BASE_FEE_EXEC_MAX, u64::MAX / MAX_GAS_EXEC); + assert_eq!(BASE_FEE_STOR_MAX, u64::MAX / MAX_GAS_STOR); + assert!(MAX_GAS_EXEC.checked_mul(BASE_FEE_EXEC_MAX).is_some()); + assert!(MAX_GAS_STOR.checked_mul(BASE_FEE_STOR_MAX).is_some()); + // Spec parameter values, pinned. + assert_eq!(BASE_FEE_EXEC_MAX, 1_844_674_407_370); + assert_eq!(BASE_FEE_STOR_MAX, 18_446_744_073_709); + } + + #[test] + fn at_target_price_is_unchanged() { + assert_eq!( + next_base_fee( + 100, + TARGET_GAS_EXEC, + TARGET_GAS_EXEC, + 8, + 8, + BASE_FEE_EXEC_MAX + ), + 100 + ); + } + + #[test] + fn live_upward_from_the_minimum() { + // One unit above target must move the price up by at least 1, even at + // b = 8 where the proportional delta rounds to 0. + let next = next_base_fee( + 8, + TARGET_GAS_EXEC + 1, + TARGET_GAS_EXEC, + 8, + 8, + BASE_FEE_EXEC_MAX, + ); + assert_eq!(next, 9); + } + + #[test] + fn full_deviation_moves_exactly_one_eighth() { + // g = 2T clamps deviation to T: delta = b/8 exactly (±12.5%). + let b = 8_000; + let up = next_base_fee( + b, + 2 * TARGET_GAS_EXEC, + TARGET_GAS_EXEC, + 8, + 8, + BASE_FEE_EXEC_MAX, + ); + assert_eq!(up, b + b / 8); + let down = next_base_fee(b, 0, TARGET_GAS_EXEC, 8, 8, BASE_FEE_EXEC_MAX); + assert_eq!(down, b - b / 8); + } + + #[test] + fn deviation_clamp_bounds_any_overshoot() { + // Usage far above 2T moves no further than the full-deviation step. + let b = 8_000; + let extreme = next_base_fee(b, u64::MAX, TARGET_GAS_EXEC, 8, 8, BASE_FEE_EXEC_MAX); + assert_eq!(extreme, b + b / 8); + } + + #[test] + fn asymmetric_at_small_prices() { + // One unit below target rounds the down-delta to zero: price holds. + let next = next_base_fee( + 100, + TARGET_GAS_EXEC - 1, + TARGET_GAS_EXEC, + 8, + 8, + BASE_FEE_EXEC_MAX, + ); + assert_eq!(next, 100); + } + + #[test] + fn saturates_at_bounds() { + // Down-step clamps at lo. + assert_eq!( + next_base_fee(8, 0, TARGET_GAS_EXEC, 8, 8, BASE_FEE_EXEC_MAX), + 8 + ); + // Up-step clamps at hi, computed with a 128-bit product (b·deviation + // would overflow u64 here). + let at_cap = next_base_fee( + BASE_FEE_EXEC_MAX, + 2 * TARGET_GAS_EXEC, + TARGET_GAS_EXEC, + 8, + 8, + BASE_FEE_EXEC_MAX, + ); + assert_eq!(at_cap, BASE_FEE_EXEC_MAX); + } + + #[test] + fn bounded_adjustment_over_a_grid() { + // |next − b| ≤ max(1, b/8) and next ∈ [lo, hi], across a price × usage + // grid including both extremes. + let prices = [ + 8, + 9, + 63, + 64, + 1_000, + 5_000_000, + BASE_FEE_EXEC_MAX - 1, + BASE_FEE_EXEC_MAX, + ]; + let usages = [ + 0, + 1, + TARGET_GAS_EXEC - 1, + TARGET_GAS_EXEC, + TARGET_GAS_EXEC + 1, + 2 * TARGET_GAS_EXEC, + u64::MAX, + ]; + for b in prices { + for g in usages { + let next = next_base_fee(b, g, TARGET_GAS_EXEC, 8, 8, BASE_FEE_EXEC_MAX); + let bound = 1.max(b / 8); + assert!( + next.abs_diff(b) <= bound, + "move too large: b={b} g={g} next={next}" + ); + assert!( + (8..=BASE_FEE_EXEC_MAX).contains(&next), + "out of range: b={b} g={g}" + ); + } + } + } +} diff --git a/lez/programs/fee/core/src/state.rs b/lez/programs/fee/core/src/state.rs new file mode 100644 index 000000000..846a94ce5 --- /dev/null +++ b/lez/programs/fee/core/src/state.rs @@ -0,0 +1,196 @@ +//! Persistent fee market state, stored in the fee-state account's data. + +#![expect( + clippy::arithmetic_side_effects, + clippy::integer_division, + clippy::integer_division_remainder_used, + reason = "spec-mandated integer math: the payout split is floor division with an explicit \ + carry, and additions are checked" +)] + +use borsh::{BorshDeserialize, BorshSerialize}; + +use crate::{BlockFeeSummary, market}; + +/// The fee market's persistent state, Borsh-serialized into the fee-state +/// account's `data`. The escrow is the escrow *account balance*, deliberately +/// not a field here. +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct FeeState { + /// Execution base fee for the current block. + pub base_fee_exec: u64, + /// Storage base fee for the current block. + pub base_fee_stor: u64, + /// Base revenue of the last [`market::SMOOTHING_WINDOW`] blocks; slot + /// `height % SMOOTHING_WINDOW` is the most recent. + pub window: [u128; market::SMOOTHING_WINDOW], + /// Payout division remainder, always < [`market::SMOOTHING_WINDOW`]. + pub payout_carry: u128, + /// Block height; an increment at 2^64 - 1 is a consensus fault. + pub height: u64, +} + +impl FeeState { + /// The state every zone starts from: both base fees at their minimum, + /// empty window, zero carry, height zero. + #[must_use] + pub const fn genesis() -> Self { + Self { + base_fee_exec: market::BASE_FEE_EXEC_MIN, + base_fee_stor: market::BASE_FEE_STOR_MIN, + window: [0; market::SMOOTHING_WINDOW], + payout_carry: 0, + height: 0, + } + } + + /// Applies one block's summary: pushes the block's base revenue into the + /// window, computes the smoothed payout and carry, updates both base fees, + /// and advances the height. Returns the payout owed to the producer from + /// escrow. + pub fn apply_block(&mut self, summary: &BlockFeeSummary) -> u128 { + self.height = self + .height + .checked_add(1) + .expect("height increment at u64::MAX is a consensus fault"); + + let window_len = u64::try_from(market::SMOOTHING_WINDOW).expect("window length fits u64"); + let slot = usize::try_from(self.height % window_len).expect("slot index fits usize"); + self.window[slot] = summary.revenue_base; + + let numerator = self.window.iter().fold(self.payout_carry, |acc, revenue| { + acc.checked_add(*revenue) + .expect("window sum of 50 u128 revenues cannot overflow in practice") + }); + let window_len = u128::from(window_len); + let payout = numerator / window_len; + self.payout_carry = numerator % window_len; + + self.base_fee_exec = market::next_base_fee( + self.base_fee_exec, + summary.gas_used_exec, + market::TARGET_GAS_EXEC, + market::D_EXEC, + market::BASE_FEE_EXEC_MIN, + market::BASE_FEE_EXEC_MAX, + ); + self.base_fee_stor = market::next_base_fee( + self.base_fee_stor, + summary.gas_used_stor, + market::TARGET_GAS_STOR, + market::D_STOR, + market::BASE_FEE_STOR_MIN, + market::BASE_FEE_STOR_MAX, + ); + + payout + } + + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("FeeState serialization should not fail") + } + + #[must_use] + pub fn from_bytes(bytes: &[u8]) -> Self { + borsh::from_slice(bytes).expect("FeeState deserialization should not fail") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::BlockFeeSummary; + + fn summary_with_revenue(revenue_base: u128) -> BlockFeeSummary { + BlockFeeSummary { + revenue_base, + ..BlockFeeSummary::default() + } + } + + #[test] + fn genesis_matches_spec() { + let state = FeeState::genesis(); + assert_eq!(state.base_fee_exec, market::BASE_FEE_EXEC_MIN); + assert_eq!(state.base_fee_stor, market::BASE_FEE_STOR_MIN); + assert_eq!(state.window, [0; market::SMOOTHING_WINDOW]); + assert_eq!(state.payout_carry, 0); + assert_eq!(state.height, 0); + } + + #[test] + fn serialization_round_trips() { + let mut state = FeeState::genesis(); + state.apply_block(&summary_with_revenue(12_345)); + assert_eq!(FeeState::from_bytes(&state.to_bytes()), state); + } + + #[test] + fn zero_load_holds_the_floor() { + let mut state = FeeState::genesis(); + for expected_height in 1..=120_u64 { + let payout = state.apply_block(&BlockFeeSummary::default()); + assert_eq!(payout, 0); + assert_eq!(state.height, expected_height); + } + assert_eq!(state.base_fee_exec, market::BASE_FEE_EXEC_MIN); + assert_eq!(state.base_fee_stor, market::BASE_FEE_STOR_MIN); + assert_eq!(state.payout_carry, 0); + } + + #[test] + fn congested_blocks_raise_both_fees() { + let mut state = FeeState::genesis(); + let full = BlockFeeSummary { + gas_used_exec: market::MAX_GAS_EXEC, + gas_used_stor: market::MAX_GAS_STOR, + ..BlockFeeSummary::default() + }; + state.apply_block(&full); + // From the floor, one congested block moves each fee up by max(1, 8/8) = 1. + assert_eq!(state.base_fee_exec, market::BASE_FEE_EXEC_MIN + 1); + assert_eq!(state.base_fee_stor, market::BASE_FEE_STOR_MIN + 1); + } + + #[test] + fn revenue_pulse_amortizes_exactly_over_the_window() { + // A pulse of R contributes to exactly 50 consecutive payouts starting + // with the collecting block, and nothing is stranded. 1234567 is + // deliberately not divisible by 50. + const PULSE: u128 = 1_234_567; + let mut state = FeeState::genesis(); + let mut paid = state.apply_block(&summary_with_revenue(PULSE)); + for _ in 0..market::SMOOTHING_WINDOW - 1 { + paid += state.apply_block(&BlockFeeSummary::default()); + } + assert_eq!(paid, PULSE, "the 50th payout completes the pulse"); + // After the window passes, nothing remains: payouts and carry are zero. + assert_eq!(state.apply_block(&BlockFeeSummary::default()), 0); + assert_eq!(state.payout_carry, 0); + } + + #[test] + fn carry_stays_below_the_window_length() { + let mut state = FeeState::genesis(); + let window_len = u128::try_from(market::SMOOTHING_WINDOW).expect("fits u128"); + for i in 0..200_u128 { + state.apply_block(&summary_with_revenue(i * 7 + 3)); + assert!(state.payout_carry < window_len); + } + } + + #[test] + fn cumulative_payout_never_exceeds_cumulative_revenue() { + // Payout ≤ escrow at every block, where escrow is cumulative revenue + // minus cumulative payouts. + let mut state = FeeState::genesis(); + let (mut revenue, mut paid): (u128, u128) = (0, 0); + for i in 0..200_u128 { + let r = (i * 31) % 97; + revenue += r; + paid += state.apply_block(&summary_with_revenue(r)); + assert!(paid <= revenue, "payout exceeded revenue at block {i}"); + } + } +} diff --git a/lez/programs/fee/src/main.rs b/lez/programs/fee/src/main.rs index 048776d8c..ebfcc9db5 100644 --- a/lez/programs/fee/src/main.rs +++ b/lez/programs/fee/src/main.rs @@ -7,10 +7,11 @@ //! before the clock invocation. Fee accounts are assigned to the fee program at //! genesis, so no claiming is required here. //! -//! Skeleton stage: verifies its accounts and echoes them unchanged; the block -//! fee summary is validated byte-for-byte (all-zero) by the transition. +//! Applies the per-block market update to the fee-state account; the block fee +//! summary is validated byte-for-byte (all-zero until metering lands), so +//! escrow and inbox stay untouched. -use fee_core::Instruction; +use fee_core::{Instruction, market, state::FeeState}; use lee_core::program::{AccountPostState, ProgramInput, ProgramOutput, read_lee_inputs}; fn main() { @@ -19,7 +20,7 @@ fn main() { self_program_id, caller_program_id, pre_states, - instruction: _summary, + instruction: summary, }, instruction_words, ) = read_lee_inputs::(); @@ -44,8 +45,26 @@ fn main() { panic!("Fee accounts must be owned by the fee program"); } + // A summary above the per-block caps is not a valid block; the transition + // also pins the summary byte-for-byte. + if summary.gas_used_exec > market::MAX_GAS_EXEC || summary.gas_used_stor > market::MAX_GAS_STOR + { + panic!("Block fee summary exceeds per-block gas caps"); + } + + let mut fee_state = FeeState::from_bytes(&pre_state.account.data.clone().into_inner()); + let payout = fee_state.apply_block(&summary); + // Until charging lands the summary is all-zero, so no payout can be owed. + assert!(payout == 0, "no payout can accrue under zero fees"); + + let mut post_state_account = pre_state.account.clone(); + post_state_account.data = fee_state + .to_bytes() + .try_into() + .expect("FeeState data should fit in account data"); + let posts = vec![ - AccountPostState::new(pre_state.account.clone()), + AccountPostState::new(post_state_account), AccountPostState::new(pre_escrow.account.clone()), AccountPostState::new(pre_inbox.account.clone()), ]; diff --git a/lez/storage/src/indexer/tests.rs b/lez/storage/src/indexer/tests.rs index 7bcbf4e79..91be072c7 100644 --- a/lez/storage/src/indexer/tests.rs +++ b/lez/storage/src/indexer/tests.rs @@ -38,10 +38,21 @@ fn initial_state() -> lee::V03State { ) }) .collect::>(); + + // push clock system accounts for clock_id in system_accounts::clock_account_ids() { public_accounts.push((clock_id, system_accounts::clock_account())); } - for fee_id in system_accounts::fee_account_ids() { + + // push fee system accounts + public_accounts.push(( + system_accounts::fee_state_account_id(), + system_accounts::fee_state_account(), + )); + for fee_id in [ + system_accounts::fee_escrow_account_id(), + system_accounts::fee_inbox_account_id(), + ] { public_accounts.push((fee_id, system_accounts::fee_account())); } diff --git a/lez/system_accounts/src/lib.rs b/lez/system_accounts/src/lib.rs index b8a0e7ee8..7b9c2bd8d 100644 --- a/lez/system_accounts/src/lib.rs +++ b/lez/system_accounts/src/lib.rs @@ -83,6 +83,20 @@ pub fn fee_account() -> Account { } } +/// The fee-state account at genesis: owned by the fee program, carrying the +/// genesis market state in its data. +#[must_use] +pub fn fee_state_account() -> Account { + Account { + program_owner: programs::fee().id(), + data: fee_core::state::FeeState::genesis() + .to_bytes() + .try_into() + .expect("FeeState data should fit"), + ..Account::default() + } +} + #[must_use] pub const fn clock_account_ids() -> [AccountId; 3] { clock_core::CLOCK_PROGRAM_ACCOUNT_IDS diff --git a/lez/testnet_initial_state/Cargo.toml b/lez/testnet_initial_state/Cargo.toml index f06d5f51f..f8d8decc6 100644 --- a/lez/testnet_initial_state/Cargo.toml +++ b/lez/testnet_initial_state/Cargo.toml @@ -13,5 +13,8 @@ programs.workspace = true serde.workspace = true +[dev-dependencies] +fee_core.workspace = true + [lints] workspace = true diff --git a/lez/testnet_initial_state/src/lib.rs b/lez/testnet_initial_state/src/lib.rs index 65b5c26fe..cdafcb6e7 100644 --- a/lez/testnet_initial_state/src/lib.rs +++ b/lez/testnet_initial_state/src/lib.rs @@ -212,11 +212,20 @@ fn initial_public_accounts() -> HashMap { .into_iter() .map(|clock_id| (clock_id, system_accounts::clock_account())), ) - .chain( - system_accounts::fee_account_ids() - .into_iter() - .map(|fee_id| (fee_id, system_accounts::fee_account())), - ) + .chain([ + ( + system_accounts::fee_state_account_id(), + system_accounts::fee_state_account(), + ), + ( + system_accounts::fee_escrow_account_id(), + system_accounts::fee_account(), + ), + ( + system_accounts::fee_inbox_account_id(), + system_accounts::fee_account(), + ), + ]) .collect() } @@ -429,6 +438,28 @@ mod tests { assert_eq!(account.program_owner, fee_program_id); assert_eq!(account.balance, 0); } + + // The fee-state account carries the genesis market state; escrow and + // inbox start empty. + let fee_state = fee_core::state::FeeState::from_bytes( + &state + .get_account_by_id(system_accounts::fee_state_account_id()) + .data + .into_inner(), + ); + assert_eq!(fee_state, fee_core::state::FeeState::genesis()); + for empty_id in [ + system_accounts::fee_escrow_account_id(), + system_accounts::fee_inbox_account_id(), + ] { + assert!( + state + .get_account_by_id(empty_id) + .data + .into_inner() + .is_empty() + ); + } } #[test] diff --git a/test_fixtures/fixtures/prebuilt_sequencer_db.dump b/test_fixtures/fixtures/prebuilt_sequencer_db.dump index fd0a2ad73..e18b57c7b 100644 Binary files a/test_fixtures/fixtures/prebuilt_sequencer_db.dump and b/test_fixtures/fixtures/prebuilt_sequencer_db.dump differ