Skip to content

Commit 3df8374

Browse files
apollo_consensus_orchestrator: wire SNIP-35 fee_proposal into build_proposal
1 parent 8286c4e commit 3df8374

6 files changed

Lines changed: 282 additions & 82 deletions

File tree

crates/apollo_consensus_orchestrator/src/build_proposal.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ pub(crate) struct ProposalBuildArguments {
7676
pub override_l2_gas_price_fri: Option<u128>,
7777
pub min_l2_gas_price_per_height: Vec<PricePerHeight>,
7878
pub compare_retrospective_block_hash: bool,
79+
/// SNIP-35: proposer's fee_proposal for this block.
80+
pub fee_proposal: GasPrice,
81+
/// SNIP-35: current fee_actual from the sliding window.
82+
pub fee_actual: Option<GasPrice>,
7983
}
8084

8185
type BuildProposalResult<T> = Result<T, BuildProposalError>;
@@ -176,7 +180,7 @@ async fn initiate_build(args: &mut ProposalBuildArguments) -> BuildProposalResul
176180
starknet_version: starknet_api::block::StarknetVersion::LATEST,
177181
// TODO(Asmaa): Put the real value once we have it.
178182
version_constant_commitment: Default::default(),
179-
fee_proposal_fri: None,
183+
fee_proposal_fri: Some(args.fee_proposal),
180184
};
181185

182186
let retrospective_block_hash = wait_for_retrospective_block_hash(
@@ -319,7 +323,7 @@ async fn get_proposal_content(
319323
info.l2_gas_used,
320324
args.override_l2_gas_price_fri,
321325
&args.min_l2_gas_price_per_height,
322-
None,
326+
args.fee_actual,
323327
);
324328
let fin_payload = ProposalFinPayload {
325329
commitment_parts: CommitmentParts::from(&info),

crates/apollo_consensus_orchestrator/src/sequencer_consensus_context.rs

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ use tokio::task::JoinHandle;
7575
use tokio::time::sleep;
7676
use tokio_util::sync::CancellationToken;
7777
use tokio_util::task::AbortOnDropHandle;
78-
use tracing::{error, error_span, info, instrument, trace, warn, Instrument};
78+
use tracing::{debug, error, error_span, info, instrument, trace, warn, Instrument};
7979

8080
use crate::build_proposal::{build_proposal, BuildProposalError, ProposalBuildArguments};
8181
use crate::cende::{
@@ -94,8 +94,19 @@ use crate::metrics::{
9494
record_validate_proposal_failure,
9595
register_metrics,
9696
CONSENSUS_L2_GAS_PRICE,
97+
SNIP35_FEE_ACTUAL,
98+
SNIP35_FEE_PROPOSAL,
99+
SNIP35_FEE_TARGET,
100+
SNIP35_STRK_USD_RATE,
101+
};
102+
use crate::snip35::{
103+
compute_fee_actual,
104+
compute_fee_proposal,
105+
compute_fee_target,
106+
FEE_PROPOSAL_MARGIN_PPT,
107+
FEE_PROPOSAL_WINDOW_SIZE,
108+
TARGET_ATTO_USD_PER_L2_GAS,
97109
};
98-
use crate::snip35::FEE_PROPOSAL_WINDOW_SIZE;
99110
use crate::utils::{
100111
convert_to_sn_api_block_info,
101112
make_gas_price_params,
@@ -398,6 +409,7 @@ impl SequencerConsensusContext {
398409
sequencer,
399410
timestamp: BlockTimestamp(init.timestamp),
400411
l1_da_mode: init.l1_da_mode,
412+
fee_proposal_fri: init.fee_proposal_fri,
401413
// TODO(guy.f): Figure out where/if to get the values below from and fill them.
402414
..Default::default()
403415
};
@@ -416,16 +428,64 @@ impl SequencerConsensusContext {
416428
/// Returns the next L2 gas price without mutating context. Used when building the fin and when
417429
/// updating at decision time.
418430
fn calculate_next_l2_gas_price(&self, height: BlockNumber, l2_gas_used: GasAmount) -> GasPrice {
431+
let fee_actual = compute_fee_actual(&self.fee_proposals_window, height);
419432
calculate_next_l2_gas_price_for_fin(
420433
self.l2_gas_price,
421434
height,
422435
l2_gas_used,
423436
self.config.dynamic_config.override_l2_gas_price_fri,
424437
&self.config.dynamic_config.min_l2_gas_price_per_height,
425-
None,
438+
fee_actual,
426439
)
427440
}
428441

442+
/// SNIP-35: compute the fee_proposal this node should publish, given the precomputed
443+
/// `fee_actual` (median of the recent fee_proposals window). When `fee_actual` is `None`
444+
/// the window is incomplete: freeze the fee_proposal at `l2_gas_price`, since without a
445+
/// real clamping center we'd otherwise emit an arbitrary value. The validator computes
446+
/// `fee_actual` the same way, so it also freezes — both sides agree.
447+
async fn compute_snip35_fee_proposal(
448+
&self,
449+
fee_actual: Option<GasPrice>,
450+
timestamp: u64,
451+
) -> GasPrice {
452+
let Some(fee_actual) = fee_actual else {
453+
warn!("fee_actual unavailable, freezing fee_proposal at l2_gas_price");
454+
SNIP35_FEE_PROPOSAL.set_lossy(self.l2_gas_price.0);
455+
return self.l2_gas_price;
456+
};
457+
SNIP35_FEE_ACTUAL.set_lossy(fee_actual.0);
458+
459+
// The instance held in `strk_to_usd_oracle` is configured with a STRK/USD endpoint;
460+
// the trait method is generic and the rate semantics are labeled by the field.
461+
let fee_target = match &self.deps.strk_to_usd_oracle {
462+
Some(oracle) => match oracle.fetch_rate(timestamp).await {
463+
Ok(rate) if rate > 0 => {
464+
SNIP35_STRK_USD_RATE.set_lossy(rate);
465+
let target = compute_fee_target(TARGET_ATTO_USD_PER_L2_GAS, rate);
466+
SNIP35_FEE_TARGET.set_lossy(target.0);
467+
Some(target)
468+
}
469+
Ok(_) => {
470+
warn!("STRK/USD oracle returned zero rate, freezing fee_proposal");
471+
None
472+
}
473+
Err(e) => {
474+
warn!("STRK/USD oracle error: {e:?}, freezing fee_proposal");
475+
None
476+
}
477+
},
478+
None => {
479+
debug!("No STRK/USD oracle configured, freezing fee_proposal");
480+
None
481+
}
482+
};
483+
484+
let proposal = compute_fee_proposal(fee_target, fee_actual, FEE_PROPOSAL_MARGIN_PPT);
485+
SNIP35_FEE_PROPOSAL.set_lossy(proposal.0);
486+
proposal
487+
}
488+
429489
fn update_l2_gas_price(&mut self, height: BlockNumber, l2_gas_used: GasAmount) {
430490
self.l2_gas_price = self.calculate_next_l2_gas_price(height, l2_gas_used);
431491
let gas_price_u64 = u64::try_from(self.l2_gas_price.0).unwrap_or(u64::MAX);
@@ -619,6 +679,7 @@ impl ConsensusContext for SequencerConsensusContext {
619679

620680
// Handles interrupting an active proposal from a previous height/round
621681
self.set_height_and_round(build_param.height, build_param.round).await?;
682+
622683
assert!(
623684
self.active_proposal.is_none(),
624685
"We should not have an existing active proposal for the (height, round) when \
@@ -656,6 +717,9 @@ impl ConsensusContext for SequencerConsensusContext {
656717
BehaviorMode::Echonet => true,
657718
BehaviorMode::Starknet => false,
658719
};
720+
let fee_actual = compute_fee_actual(&self.fee_proposals_window, build_param.height);
721+
let fee_proposal =
722+
self.compute_snip35_fee_proposal(fee_actual, self.deps.clock.unix_now()).await;
659723
let round = build_param.round;
660724
let args = ProposalBuildArguments {
661725
deps: self.deps.clone(),
@@ -689,6 +753,8 @@ impl ConsensusContext for SequencerConsensusContext {
689753
.config
690754
.dynamic_config
691755
.compare_retrospective_block_hash,
756+
fee_proposal,
757+
fee_actual,
692758
};
693759

694760
let handle = tokio::spawn(

crates/apollo_consensus_orchestrator/src/sequencer_consensus_context_test.rs

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ use apollo_l1_gas_price_types::errors::{
2727
L1GasPriceClientError,
2828
L1GasPriceProviderError,
2929
};
30-
use apollo_l1_gas_price_types::{MockL1GasPriceProviderClient, PriceInfo};
30+
use apollo_l1_gas_price_types::{
31+
MockExchangeRateOracleClientTrait,
32+
MockL1GasPriceProviderClient,
33+
PriceInfo,
34+
};
3135
use apollo_protobuf::consensus::{
3236
BuildParam,
3337
CommitmentParts,
@@ -1601,3 +1605,107 @@ async fn test_initialize_fee_proposals_window(
16011605
context.initialize_fee_proposals_window(start_height).await.unwrap();
16021606
assert_eq!(context.fee_proposals_window, expected_window);
16031607
}
1608+
1609+
#[derive(Clone)]
1610+
enum OracleBehavior {
1611+
/// No oracle is configured (`strk_to_usd_oracle = None`).
1612+
NotConfigured,
1613+
/// Oracle is configured and `fetch_rate` returns `Ok(rate)`.
1614+
Ok(u128),
1615+
/// Oracle is configured and `fetch_rate` returns `Err(_)`.
1616+
Err,
1617+
}
1618+
1619+
// `compute_snip35_fee_proposal` decides the published fee_proposal from the precomputed
1620+
// `fee_actual` and the STRK/USD oracle. Each case fixes both inputs (fee_actual + oracle
1621+
// behavior + l2_gas_price) and the expected published fee_proposal.
1622+
//
1623+
// Geometric bounds (with FEE_PROPOSAL_MARGIN_PPT = 2, PPT_DENOMINATOR = 1000):
1624+
// for fee_actual = 10_000_000_000 (10 gwei),
1625+
// upper = fee_actual * 1002 / 1000 = 10_020_000_000
1626+
// lower = fee_actual * 1000 / 1002 = 9_980_039_920
1627+
//
1628+
// Oracle target = TARGET_ATTO_USD_PER_L2_GAS * 1e18 / strk_usd_rate (u128 saturating):
1629+
// rate = 1 -> floor = 3e27, saturates to u128::MAX (very high target)
1630+
// rate = 3 * 10^17 -> floor = 1e10 (exactly fee_actual; target in bounds)
1631+
// rate = 10^21 -> floor = 3e6 (low target, well below lower bound)
1632+
#[rstest]
1633+
// `fee_actual = None` => freeze at `l2_gas_price`; oracle is not consulted.
1634+
#[case::no_fee_actual_freezes_at_l2_gas_price(
1635+
None,
1636+
OracleBehavior::NotConfigured,
1637+
GasPrice(7_000_000_000),
1638+
GasPrice(7_000_000_000)
1639+
)]
1640+
// No oracle => `fee_target` is `None` => `compute_fee_proposal` returns `fee_actual` unchanged.
1641+
#[case::no_oracle_freezes_at_fee_actual(
1642+
Some(GasPrice(10_000_000_000)),
1643+
OracleBehavior::NotConfigured,
1644+
GasPrice(7_000_000_000),
1645+
GasPrice(10_000_000_000)
1646+
)]
1647+
// Oracle returns 0 => treated as invalid => freeze at `fee_actual`.
1648+
#[case::oracle_zero_rate_freezes_at_fee_actual(
1649+
Some(GasPrice(10_000_000_000)),
1650+
OracleBehavior::Ok(0),
1651+
GasPrice(7_000_000_000),
1652+
GasPrice(10_000_000_000)
1653+
)]
1654+
// Oracle errors => freeze at `fee_actual`.
1655+
#[case::oracle_err_freezes_at_fee_actual(
1656+
Some(GasPrice(10_000_000_000)),
1657+
OracleBehavior::Err,
1658+
GasPrice(7_000_000_000),
1659+
GasPrice(10_000_000_000)
1660+
)]
1661+
// Oracle target in bounds (== fee_actual) => published as-is.
1662+
#[case::oracle_target_in_bounds_returns_target(
1663+
Some(GasPrice(10_000_000_000)),
1664+
OracleBehavior::Ok(300_000_000_000_000_000),
1665+
GasPrice(7_000_000_000),
1666+
GasPrice(10_000_000_000)
1667+
)]
1668+
// Oracle target very high => clamps to upper bound.
1669+
#[case::oracle_target_above_clamps_to_upper(
1670+
Some(GasPrice(10_000_000_000)),
1671+
OracleBehavior::Ok(1),
1672+
GasPrice(7_000_000_000),
1673+
GasPrice(10_020_000_000)
1674+
)]
1675+
// Oracle target very low => clamps to lower bound.
1676+
#[case::oracle_target_below_clamps_to_lower(
1677+
Some(GasPrice(10_000_000_000)),
1678+
OracleBehavior::Ok(1_000_000_000_000_000_000_000),
1679+
GasPrice(7_000_000_000),
1680+
GasPrice(9_980_039_920)
1681+
)]
1682+
#[tokio::test]
1683+
async fn test_compute_snip35_fee_proposal(
1684+
#[case] fee_actual: Option<GasPrice>,
1685+
#[case] oracle_behavior: OracleBehavior,
1686+
#[case] l2_gas_price: GasPrice,
1687+
#[case] expected_fee_proposal: GasPrice,
1688+
) {
1689+
let (mut deps, _network) = create_test_and_network_deps();
1690+
deps.setup_default_expectations();
1691+
deps.strk_to_usd_oracle = match oracle_behavior {
1692+
OracleBehavior::NotConfigured => None,
1693+
OracleBehavior::Ok(rate) => {
1694+
let mut mock = MockExchangeRateOracleClientTrait::new();
1695+
mock.expect_fetch_rate().returning(move |_| Ok(rate));
1696+
Some(Arc::new(mock))
1697+
}
1698+
OracleBehavior::Err => {
1699+
let mut mock = MockExchangeRateOracleClientTrait::new();
1700+
mock.expect_fetch_rate().returning(|_| {
1701+
Err(ExchangeRateOracleClientError::RequestError("test".to_string()))
1702+
});
1703+
Some(Arc::new(mock))
1704+
}
1705+
};
1706+
1707+
let mut context = deps.build_context();
1708+
context.l2_gas_price = l2_gas_price;
1709+
let proposal = context.compute_snip35_fee_proposal(fee_actual, 0).await;
1710+
assert_eq!(proposal, expected_fee_proposal);
1711+
}

crates/apollo_consensus_orchestrator/src/snip35/mod.rs

Lines changed: 55 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
//! See also: `fee_market` for EIP-1559-style base-fee adjustment, which receives
1313
//! `fee_actual` as a floor.
1414
15+
use std::collections::BTreeMap;
16+
1517
use ethnum::U256;
16-
use starknet_api::block::GasPrice;
18+
use starknet_api::block::{BlockNumber, GasPrice};
19+
use tracing::warn;
1720

1821
#[cfg(test)]
1922
mod test;
@@ -28,54 +31,74 @@ pub(crate) const PPT_DENOMINATOR: u128 = 1000;
2831
// TODO(AndrewL): consider moving this to versioned constants.
2932
pub(crate) const FEE_PROPOSAL_WINDOW_SIZE: usize = 10;
3033

31-
/// Compute fee_actual from the last `window_size` `fee_proposal` values (SNIP-35).
34+
/// Maximum fee_proposal change per block in parts per thousand (SNIP-35: 0.2%).
35+
// TODO(AndrewL): consider moving this to versioned constants.
36+
pub(crate) const FEE_PROPOSAL_MARGIN_PPT: u128 = 2;
37+
38+
/// Target USD cost per L2 gas unit in atto-USD ($3e-9 = 3_000_000_000 atto-USD).
39+
// TODO(AndrewL): consider moving this to a dynamic config.
40+
pub(crate) const TARGET_ATTO_USD_PER_L2_GAS: u128 = 3_000_000_000;
41+
42+
/// Compute fee_actual for `height` as the median of the `fee_proposal` values
43+
/// recorded for heights `[height - FEE_PROPOSAL_WINDOW_SIZE, height - 1]` (SNIP-35).
3244
///
33-
/// Median rule: for even `window_size`, the average of the two middle values rounded
34-
/// down; for odd `window_size`, the single middle value.
45+
/// Returns `None` (after logging a warning) when any of those heights is missing from
46+
/// `fee_proposals_window` or recorded as `None` (e.g., pre-SNIP-35 blocks). The `None`
47+
/// case triggers the `l2_gas_price` fallback in both proposer and validator paths.
3548
///
36-
/// Returns `None` if `window_size == 0`, fewer than `window_size` proposals are
37-
/// available, or the median is zero (e.g., pre-SNIP-35 blocks with `fee_proposal == 0`).
38-
/// The `None` case triggers the `l2_gas_price` fallback in both proposer and validator
39-
/// paths.
40-
pub fn compute_fee_actual(proposals: &[GasPrice], window_size: usize) -> Option<GasPrice> {
41-
if window_size == 0 {
49+
/// Median rule for even `FEE_PROPOSAL_WINDOW_SIZE`: average of the two middle values
50+
/// rounded down; for odd: the single middle value.
51+
pub fn compute_fee_actual(
52+
fee_proposals_window: &BTreeMap<BlockNumber, Option<GasPrice>>,
53+
height: BlockNumber,
54+
) -> Option<GasPrice> {
55+
let window_size =
56+
u64::try_from(FEE_PROPOSAL_WINDOW_SIZE).expect("FEE_PROPOSAL_WINDOW_SIZE fits in u64");
57+
let Some(start) = height.0.checked_sub(window_size) else {
58+
warn!(
59+
"Cannot compute fee_actual for height {height}: height is below \
60+
FEE_PROPOSAL_WINDOW_SIZE ({FEE_PROPOSAL_WINDOW_SIZE})"
61+
);
4262
return None;
63+
};
64+
let mut window = Vec::with_capacity(FEE_PROPOSAL_WINDOW_SIZE);
65+
for source_height in (start..height.0).map(BlockNumber) {
66+
match fee_proposals_window.get(&source_height) {
67+
Some(Some(price)) => window.push(*price),
68+
Some(None) | None => {
69+
warn!(
70+
"Cannot compute fee_actual for height {height}: fee_proposals_window has no \
71+
recorded fee_proposal for height {source_height}"
72+
);
73+
return None;
74+
}
75+
}
4376
}
44-
let start = proposals.len().checked_sub(window_size)?;
45-
let window = proposals.get(start..)?;
46-
let mut sorted: Vec<GasPrice> = window.to_vec();
47-
sorted.sort();
48-
let mid = window_size / 2;
49-
let median = if window_size.is_multiple_of(2) {
77+
window.sort();
78+
let mid = FEE_PROPOSAL_WINDOW_SIZE / 2;
79+
let median = if FEE_PROPOSAL_WINDOW_SIZE.is_multiple_of(2) {
5080
// Even: average of the two middle values, rounded down.
5181
// Overflow-safe averaging: a + (b - a) / 2 (safe because sorted, so b >= a).
52-
GasPrice(sorted[mid - 1].0 + (sorted[mid].0 - sorted[mid - 1].0) / 2)
82+
GasPrice(window[mid - 1].0 + (window[mid].0 - window[mid - 1].0) / 2)
5383
} else {
54-
sorted[mid]
84+
window[mid]
5585
};
56-
if median.0 == 0 { None } else { Some(median) }
86+
Some(median)
5787
}
5888

5989
/// Compute the fee target from STRK/USD price and a USD cost target (SNIP-35).
6090
///
6191
/// `target_atto_usd_per_l2_gas` is in atto-USD (atto = 10⁻¹⁸; so a value of
6292
/// `3_000_000_000` means 3·10⁻⁹ USD = 3 nanodollars per L2 gas unit).
63-
/// `strk_usd_rate` is the STRK/USD price with 18 decimals (from the oracle).
64-
/// Result is in FRI, clamped to `[floor_min_fri, floor_max_fri]`.
65-
pub fn compute_fee_target(
66-
target_atto_usd_per_l2_gas: u128,
67-
strk_usd_rate: u128,
68-
floor_min_fri: u128,
69-
floor_max_fri: u128,
70-
) -> GasPrice {
71-
if strk_usd_rate == 0 {
72-
return GasPrice(floor_max_fri);
73-
}
93+
/// `strk_usd_rate` is the STRK/USD price with 18 decimals (from the oracle); callers must
94+
/// filter `rate == 0` before invoking (treat as oracle failure).
95+
/// Returns the target in FRI; the multiplicative margin clamp in `compute_fee_proposal`
96+
/// keeps the published proposal bounded relative to `fee_actual`.
97+
pub fn compute_fee_target(target_atto_usd_per_l2_gas: u128, strk_usd_rate: u128) -> GasPrice {
7498
// floor_fri = target_atto_usd_per_l2_gas * 10^18 / strk_usd_rate
7599
let numerator = U256::from(target_atto_usd_per_l2_gas) * U256::from(FRI_DECIMALS_SCALE);
76100
let floor = numerator / U256::from(strk_usd_rate);
77-
let floor_u128 = u128::try_from(floor).unwrap_or(u128::MAX);
78-
GasPrice(floor_u128.clamp(floor_min_fri, floor_max_fri))
101+
GasPrice(u128::try_from(floor).unwrap_or(u128::MAX))
79102
}
80103

81104
/// Compute the fee_proposal an honest proposer should publish (SNIP-35).

0 commit comments

Comments
 (0)