Skip to content

Commit ed07f9e

Browse files
apollo_consensus_orchestrator: add SNIP-35 fee_proposals sliding window
1 parent 83db7ff commit ed07f9e

5 files changed

Lines changed: 93 additions & 13 deletions

File tree

crates/apollo_consensus_orchestrator/src/sequencer_consensus_context.rs

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
mod sequencer_consensus_context_test;
77

88
use std::cmp::max;
9-
use std::collections::{BTreeMap, HashMap};
9+
use std::collections::{BTreeMap, HashMap, VecDeque};
1010
use std::sync::{Arc, Mutex};
1111
use std::time::Duration;
1212

@@ -91,6 +91,7 @@ use crate::metrics::{
9191
register_metrics,
9292
CONSENSUS_L2_GAS_PRICE,
9393
};
94+
use crate::snip35::FEE_PROPOSAL_WINDOW_SIZE;
9495
use crate::utils::{
9596
convert_to_sn_api_block_info,
9697
make_gas_price_params,
@@ -234,6 +235,8 @@ pub struct SequencerConsensusContext {
234235
l2_gas_price: GasPrice,
235236
l1_da_mode: L1DataAvailabilityMode,
236237
previous_proposal_init: Option<PreviousProposalInitInfo>,
238+
/// SNIP-35: sliding window of recent fee_proposal values (size from config).
239+
fee_proposals_window: VecDeque<GasPrice>,
237240
}
238241

239242
#[derive(Clone)]
@@ -282,9 +285,21 @@ impl SequencerConsensusContext {
282285
l2_gas_price: VersionedConstants::latest_constants().min_gas_price,
283286
l1_da_mode,
284287
previous_proposal_init: None,
288+
fee_proposals_window: VecDeque::with_capacity(FEE_PROPOSAL_WINDOW_SIZE),
285289
}
286290
}
287291

292+
/// SNIP-35: FIFO append into the fee_proposal sliding window. When the window is at
293+
/// `FEE_PROPOSAL_WINDOW_SIZE` the oldest entry is evicted so the window always holds the
294+
/// `FEE_PROPOSAL_WINDOW_SIZE` most-recently-committed `fee_proposal` values; the median of
295+
/// this window is the `fee_actual` that both proposer and validator use.
296+
fn push_fee_proposal(&mut self, fee_proposal: GasPrice) {
297+
if self.fee_proposals_window.len() >= FEE_PROPOSAL_WINDOW_SIZE {
298+
self.fee_proposals_window.pop_front();
299+
}
300+
self.fee_proposals_window.push_back(fee_proposal);
301+
}
302+
288303
async fn start_stream(&mut self, stream_id: HeightAndRound) -> StreamSender {
289304
let (proposal_sender, proposal_receiver) =
290305
mpsc::channel(self.config.static_config.proposal_buffer_size);
@@ -387,6 +402,9 @@ impl SequencerConsensusContext {
387402
let DecisionReachedResponse { state_diff, central_objects } = decision_reached_response;
388403

389404
self.update_l2_gas_price(height, l2_gas_used);
405+
if let Some(fee_proposal) = init.fee_proposal_fri {
406+
self.push_fee_proposal(fee_proposal);
407+
}
390408

391409
// A hash map of (possibly failed) transactions, where the key is the transaction hash
392410
// and the value is the transaction itself.
@@ -839,6 +857,12 @@ impl ConsensusContext for SequencerConsensusContext {
839857
sync_block.block_header_without_hash.next_l2_gas_price,
840858
VersionedConstants::latest_constants().min_gas_price,
841859
);
860+
// SNIP-35: push fee_proposal from synced block into the sliding window.
861+
// Pre-V0_14_3 blocks don't carry a fee_proposal; skip them.
862+
if let Some(fee_proposal) = sync_block.block_header_without_hash.fee_proposal_fri {
863+
self.push_fee_proposal(fee_proposal);
864+
}
865+
842866
// TODO(Asmaa): validate starknet_version and parent_hash when they are stored.
843867
let block_number = sync_block.block_header_without_hash.block_number;
844868
let timestamp = sync_block.block_header_without_hash.timestamp;
@@ -895,6 +919,37 @@ impl ConsensusContext for SequencerConsensusContext {
895919
let gas_price_u64 = u64::try_from(self.l2_gas_price.0).unwrap_or(u64::MAX);
896920
CONSENSUS_L2_GAS_PRICE.set_lossy(gas_price_u64);
897921
}
922+
// SNIP-35: on first height (startup), backfill the fee_proposals window from stored
923+
// blocks so fee_actual can be computed immediately. Sequential to maintain insertion
924+
// order (oldest first).
925+
if self.current_height.is_none() && self.fee_proposals_window.is_empty() {
926+
let window_size = u64::try_from(FEE_PROPOSAL_WINDOW_SIZE)
927+
.expect("FEE_PROPOSAL_WINDOW_SIZE fits in u64");
928+
let start = height.0.saturating_sub(window_size);
929+
for h in start..height.0 {
930+
match self.deps.state_sync_client.get_block(BlockNumber(h)).await {
931+
Ok(block) => {
932+
if let Some(fee_proposal) =
933+
block.block_header_without_hash.fee_proposal_fri
934+
{
935+
self.push_fee_proposal(fee_proposal);
936+
}
937+
}
938+
Err(e) => {
939+
// Stop backfilling on the first error (likely `BlockNotFound` for
940+
// heights before the chain was SNIP-35-enabled). The remaining window
941+
// slots stay empty and `fee_actual` will be `None` until enough blocks
942+
// are committed, falling back to `l2_gas_price`.
943+
warn!(
944+
"SNIP-35 backfill stopped at block {h}: {e:?}. Window has {} / \
945+
{FEE_PROPOSAL_WINDOW_SIZE} entries.",
946+
self.fee_proposals_window.len()
947+
);
948+
break;
949+
}
950+
}
951+
}
952+
}
898953
self.current_height = Some(height);
899954
self.current_round = round;
900955
self.queued_proposals.clear();

crates/apollo_consensus_orchestrator/src/sequencer_consensus_context_test.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1493,15 +1493,17 @@ async fn test_first_height_keeps_sync_provided_l2_gas_price() {
14931493
const CONFIG_MIN_PRICE_AT_250: u128 = 25_000_000_000;
14941494

14951495
let (mut deps, _network) = create_test_and_network_deps();
1496-
deps.setup_default_expectations();
1497-
deps.batcher.expect_add_sync_block().times(1).return_once(|_| Ok(()));
1498-
deps.batcher.expect_start_height().times(2).returning(|_| Ok(()));
1496+
// Specific get_block expectation must be registered before setup_default_expectations, which
1497+
// installs a catch-all BlockNotFound handler for SNIP-35 backfill.
14991498
deps.state_sync_client.expect_get_block().times(1).return_once(|height| {
15001499
let mut sync_block = SyncBlock::default();
15011500
sync_block.block_header_without_hash.block_number = height;
15021501
sync_block.block_header_without_hash.next_l2_gas_price = GasPrice(SYNCED_NEXT_L2_GAS_PRICE);
15031502
Ok(sync_block)
15041503
});
1504+
deps.setup_default_expectations();
1505+
deps.batcher.expect_add_sync_block().times(1).return_once(|_| Ok(()));
1506+
deps.batcher.expect_start_height().times(2).returning(|_| Ok(()));
15051507

15061508
let mut context = deps.build_context();
15071509
context.config.dynamic_config.min_l2_gas_price_per_height =

crates/apollo_consensus_orchestrator/src/snip35/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ const FRI_DECIMALS_SCALE: u128 = 10u128.pow(18);
2424
/// Denominator for parts-per-thousand calculations in SNIP-35 fee_proposal bounds.
2525
pub(crate) const PPT_DENOMINATOR: u128 = 1000;
2626

27+
/// Number of fee_proposal values used to compute fee_actual (SNIP-35).
28+
pub(crate) const FEE_PROPOSAL_WINDOW_SIZE: usize = 10;
29+
2730
/// Compute fee_actual from the last `window_size` `fee_proposal` values (SNIP-35).
2831
///
2932
/// Median rule: for even `window_size`, the average of the two middle values rounded

crates/apollo_consensus_orchestrator/src/snip35/test.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ fn test_compute_fee_actual_random_window() {
2929
assert_eq!(compute_fee_actual(&proposals, 12), Some(GasPrice(292)));
3030
}
3131

32+
#[test]
33+
fn test_compute_fee_actual_window_size_one_returns_most_recent() {
34+
let proposals = vec![GasPrice(100), GasPrice(200), GasPrice(300)];
35+
assert_eq!(compute_fee_actual(&proposals, 1), Some(GasPrice(300)));
36+
}
37+
3238
#[rstest]
3339
#[case::window_size_zero(vec![GasPrice(100); 10], 0)]
3440
#[case::fewer_proposals_than_window(vec![GasPrice(100); 9], 10)]

crates/apollo_consensus_orchestrator/src/test_utils.rs

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ impl TestDeps {
173173
self.setup_default_transaction_converter();
174174
self.setup_default_cende_ambassador();
175175
self.setup_default_gas_price_provider();
176+
self.setup_default_state_sync_get_block();
176177
}
177178

178179
pub(crate) fn setup_deps_for_build(&mut self, args: SetupDepsArgs) {
@@ -324,24 +325,37 @@ impl TestDeps {
324325
self.l1_gas_price_provider.expect_get_rate().return_const(Ok(ETH_TO_FRI_RATE));
325326
}
326327

328+
/// Default get_block returns NotFound for all blocks. Used by SNIP-35 backfill on startup.
329+
fn setup_default_state_sync_get_block(&mut self) {
330+
self.state_sync_client.expect_get_block().returning(|block_number| {
331+
Err(apollo_state_sync_types::communication::StateSyncClientError::StateSyncError(
332+
apollo_state_sync_types::errors::StateSyncError::BlockNotFound(block_number),
333+
))
334+
});
335+
}
336+
327337
pub(crate) fn setup_default_batcher_get_block_hash(&mut self) {
328338
self.batcher.expect_get_block_hash().returning(|block_number| {
329339
Err(BatcherClientError::BatcherError(BatcherError::BlockHashNotFound(block_number)))
330340
});
331341
}
332342

333343
pub(crate) fn build_context(self) -> SequencerConsensusContext {
334-
SequencerConsensusContext::new(
335-
ContextConfig {
336-
static_config: ContextStaticConfig {
337-
proposal_buffer_size: CHANNEL_SIZE,
338-
chain_id: CHAIN_ID,
339-
..Default::default()
340-
},
344+
self.build_context_with_config(ContextConfig {
345+
static_config: ContextStaticConfig {
346+
proposal_buffer_size: CHANNEL_SIZE,
347+
chain_id: CHAIN_ID,
341348
..Default::default()
342349
},
343-
self.into(),
344-
)
350+
..Default::default()
351+
})
352+
}
353+
354+
pub(crate) fn build_context_with_config(
355+
self,
356+
config: ContextConfig,
357+
) -> SequencerConsensusContext {
358+
SequencerConsensusContext::new(config, self.into())
345359
}
346360
}
347361

0 commit comments

Comments
 (0)