feat(fees): fee market computations under zero load

This commit is contained in:
erhant
2026-08-17 15:30:28 +03:00
parent f5a8f96344
commit b937a897e7
31 changed files with 538 additions and 11 deletions
Generated
+3
View File
@@ -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",
+1
View File
@@ -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",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -25,4 +25,5 @@ tokio.workspace = true
[dev-dependencies]
testnet_initial_state.workspace = true
system_accounts.workspace = true
serde_json.workspace = true
+38
View File
@@ -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();
+1
View File
@@ -8,5 +8,6 @@ license = { workspace = true }
workspace = true
[dependencies]
borsh.workspace = true
lee_core.workspace = true
serde.workspace = true
+3
View File
@@ -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/";
+206
View File
@@ -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}"
);
}
}
}
}
+196
View File
@@ -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<u8> {
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}");
}
}
}
+24 -5
View File
@@ -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::<Instruction>();
@@ -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()),
];
+12 -1
View File
@@ -38,10 +38,21 @@ fn initial_state() -> lee::V03State {
)
})
.collect::<Vec<_>>();
// 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()));
}
+14
View File
@@ -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
+3
View File
@@ -13,5 +13,8 @@ programs.workspace = true
serde.workspace = true
[dev-dependencies]
fee_core.workspace = true
[lints]
workspace = true
+36 -5
View File
@@ -212,11 +212,20 @@ fn initial_public_accounts() -> HashMap<AccountId, Account> {
.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]
Binary file not shown.