Skip to content

Commit 9fb9c0b

Browse files
apollo_consensus_orchestrator: add SNIP-35 fee_proposal validation (#13819)
1 parent e9102f1 commit 9fb9c0b

4 files changed

Lines changed: 114 additions & 7 deletions

File tree

crates/apollo_consensus_orchestrator/src/sequencer_consensus_context.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,7 @@ impl ConsensusContext for SequencerConsensusContext {
828828
.map(GasPrice)
829829
.unwrap_or(self.l2_gas_price),
830830
starknet_version: StarknetVersion::LATEST,
831+
fee_actual: compute_fee_actual(&self.fee_proposals_window, init.height),
831832
};
832833
self.validate_current_round_proposal(
833834
init,
@@ -1094,6 +1095,7 @@ impl ConsensusContext for SequencerConsensusContext {
10941095
.map(GasPrice)
10951096
.unwrap_or(self.l2_gas_price),
10961097
starknet_version: StarknetVersion::LATEST,
1098+
fee_actual: compute_fee_actual(&self.fee_proposals_window, init.height),
10971099
};
10981100
self.validate_current_round_proposal(
10991101
init,

crates/apollo_consensus_orchestrator/src/test_utils.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -426,11 +426,8 @@ pub(crate) fn proposal_init(height: BlockNumber, round: u32) -> ProposalInit {
426426
l1_data_gas_price_wei,
427427
starknet_version: starknet_api::block::StarknetVersion::LATEST,
428428
version_constant_commitment: Default::default(),
429-
// Match the proposer's default-fallback fee_proposal: empty sliding window →
430-
// `compute_fee_actual` returns `None` → `compute_snip35_fee_proposal` falls back to
431-
// `self.l2_gas_price` (= `min_gas_price` = 8 gwei). Validator-side tests reuse this
432-
// helper to fabricate an init that matches what the proposer emits, so proposer and
433-
// validator agree on the proposal commitment hash.
429+
// SNIP-35: required because LATEST >= V0_14_3. Match the proposer's default-fallback
430+
// fee_proposal.
434431
fee_proposal_fri: Some(GasPrice(8_000_000_000)),
435432
}
436433
}

crates/apollo_consensus_orchestrator/src/validate_proposal.rs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ use crate::metrics::{
4242
CONSENSUS_PROPOSAL_FIN_MISMATCH,
4343
};
4444
use crate::sequencer_consensus_context::{BuiltProposals, SequencerConsensusContextDeps};
45-
use crate::snip35::proposal_commitment_from;
45+
use crate::snip35::{fee_proposal_bounds, proposal_commitment_from, FEE_PROPOSAL_MARGIN_PPT};
4646
use crate::utils::{
4747
convert_to_sn_api_block_info,
4848
get_l1_prices_in_fri_and_wei,
@@ -78,6 +78,9 @@ pub(crate) struct ProposalInitValidation {
7878
pub l1_da_mode: L1DataAvailabilityMode,
7979
pub l2_gas_price_fri: GasPrice,
8080
pub starknet_version: StarknetVersion,
81+
/// SNIP-35: fee_actual from the sliding window. `None` until the window has accumulated
82+
/// `FEE_PROPOSAL_WINDOW_SIZE` entries (startup / near-genesis).
83+
pub fee_actual: Option<GasPrice>,
8184
}
8285

8386
/// Parameters for deadline and cancellation handling during proposal finalization.
@@ -346,6 +349,52 @@ async fn is_proposal_init_valid(
346349
),
347350
));
348351
}
352+
353+
// SNIP-35: fee_proposal is required iff Starknet version >= V0_14_3.
354+
let snip35_active = init_proposed.starknet_version >= StarknetVersion::V0_14_3;
355+
match (init_proposed.fee_proposal_fri, snip35_active) {
356+
(Some(_), false) => {
357+
return Err(ValidateProposalError::InvalidProposalInit(
358+
init_proposed.clone(),
359+
proposal_init_validation.clone(),
360+
format!(
361+
"fee_proposal must be absent before V0_14_3, got Some at version {}",
362+
init_proposed.starknet_version
363+
),
364+
));
365+
}
366+
(None, true) => {
367+
return Err(ValidateProposalError::InvalidProposalInit(
368+
init_proposed.clone(),
369+
proposal_init_validation.clone(),
370+
format!(
371+
"fee_proposal is required at V0_14_3+, got None at version {}",
372+
init_proposed.starknet_version
373+
),
374+
));
375+
}
376+
_ => {}
377+
}
378+
379+
// SNIP-35: validate fee_proposal is within the configured margin of fee_actual.
380+
// During initiation (fee_actual is None, <window_size blocks), bounds are not enforced.
381+
if let (Some(fee_actual), Some(fee_proposal)) =
382+
(proposal_init_validation.fee_actual, init_proposed.fee_proposal_fri)
383+
{
384+
let (lower_bound, upper_bound) = fee_proposal_bounds(fee_actual, FEE_PROPOSAL_MARGIN_PPT);
385+
if fee_proposal.0 < lower_bound || fee_proposal.0 > upper_bound {
386+
return Err(ValidateProposalError::InvalidProposalInit(
387+
init_proposed.clone(),
388+
proposal_init_validation.clone(),
389+
format!(
390+
"Fee proposal out of SNIP-35 bounds: fee_actual={}, fee_proposal={}, allowed \
391+
range=[{lower_bound}, {upper_bound}]",
392+
fee_actual.0, fee_proposal.0
393+
),
394+
));
395+
}
396+
}
397+
349398
Ok(())
350399
}
351400

crates/apollo_consensus_orchestrator/src/validate_proposal_test.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ use starknet_types_core::felt::Felt;
3333
use tokio_util::sync::CancellationToken;
3434

3535
use crate::sequencer_consensus_context::BuiltProposals;
36-
use crate::snip35::proposal_commitment_from;
36+
use crate::snip35::{proposal_commitment_from, FEE_PROPOSAL_MARGIN_PPT, PPT_DENOMINATOR};
3737
use crate::test_utils::{
3838
create_test_and_network_deps,
3939
proposal_init,
@@ -104,6 +104,7 @@ fn create_proposal_validate_arguments()
104104
l1_da_mode: L1DataAvailabilityMode::Blob,
105105
l2_gas_price_fri: VersionedConstants::latest_constants().min_gas_price,
106106
starknet_version: StarknetVersion::LATEST,
107+
fee_actual: None,
107108
};
108109
let proposal_id = ProposalId(1);
109110
let timeout = TIMEOUT;
@@ -302,6 +303,64 @@ async fn rejects_proposal_init_l1_gas_price_out_of_margin(#[case] field: L1GasPr
302303
);
303304
}
304305

306+
// fee_actual = 8 gwei; bounds are derived in-line so they stay correct if the SNIP-35
307+
// constants change. upper = fee_actual * (PPT + MARGIN) / PPT;
308+
// lower = fee_actual * PPT / (PPT + MARGIN) (integer-truncated).
309+
const FEE_ACTUAL_FRI: u128 = 8_000_000_000;
310+
#[rstest]
311+
#[case::at_fee_actual(FEE_ACTUAL_FRI, true)]
312+
#[case::upper_bound_inclusive(
313+
FEE_ACTUAL_FRI * (PPT_DENOMINATOR + FEE_PROPOSAL_MARGIN_PPT) / PPT_DENOMINATOR,
314+
true,
315+
)]
316+
#[case::lower_bound_inclusive(
317+
FEE_ACTUAL_FRI * PPT_DENOMINATOR / (PPT_DENOMINATOR + FEE_PROPOSAL_MARGIN_PPT),
318+
true,
319+
)]
320+
#[case::above_upper_bound(
321+
FEE_ACTUAL_FRI * (PPT_DENOMINATOR + FEE_PROPOSAL_MARGIN_PPT) / PPT_DENOMINATOR + 1,
322+
false,
323+
)]
324+
#[case::below_lower_bound(
325+
FEE_ACTUAL_FRI * PPT_DENOMINATOR / (PPT_DENOMINATOR + FEE_PROPOSAL_MARGIN_PPT) - 1,
326+
false,
327+
)]
328+
#[tokio::test]
329+
async fn snip35_fee_proposal_within_margin_of_fee_actual(
330+
#[case] fee_proposal_fri: u128,
331+
#[case] should_accept: bool,
332+
) {
333+
let (proposal_args, _content_sender) = create_proposal_validate_arguments();
334+
let TestProposalValidateArguments {
335+
deps,
336+
mut init,
337+
mut proposal_init_validation,
338+
gas_price_params,
339+
..
340+
} = proposal_args;
341+
proposal_init_validation.fee_actual = Some(GasPrice(8_000_000_000));
342+
init.fee_proposal_fri = Some(GasPrice(fee_proposal_fri));
343+
344+
let res = is_proposal_init_valid(
345+
&proposal_init_validation,
346+
&init,
347+
deps.clock.as_ref(),
348+
Arc::new(deps.l1_gas_price_provider),
349+
&gas_price_params,
350+
)
351+
.await;
352+
353+
if should_accept {
354+
assert!(res.is_ok(), "expected accept, got {res:?}");
355+
} else {
356+
assert_matches!(
357+
res,
358+
Err(ValidateProposalError::InvalidProposalInit(_, _, ref msg))
359+
if msg.contains("Fee proposal out of SNIP-35 bounds")
360+
);
361+
}
362+
}
363+
305364
#[tokio::test]
306365
async fn validate_block_fail() {
307366
let (mut proposal_args, _content_sender) = create_proposal_validate_arguments();

0 commit comments

Comments
 (0)