Skip to content

Commit 3ea43dd

Browse files
apollo_consensus_orchestrator: add SNIP-35 proposer-validator symmetry tests
1 parent 6d05199 commit 3ea43dd

4 files changed

Lines changed: 92 additions & 39 deletions

File tree

Cargo.lock

Lines changed: 21 additions & 32 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/apollo_consensus_orchestrator/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ metrics-exporter-prometheus.workspace = true
7777
mockall.workspace = true
7878
mockito.workspace = true
7979
num-bigint.workspace = true
80+
rand.workspace = true
81+
rand_chacha.workspace = true
8082
rstest.workspace = true
8183
serde_json.workspace = true
8284

crates/apollo_consensus_orchestrator/src/sequencer_consensus_context.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -816,7 +816,7 @@ impl ConsensusContext for SequencerConsensusContext {
816816
.map(GasPrice)
817817
.unwrap_or(self.l2_gas_price),
818818
starknet_version: StarknetVersion::LATEST,
819-
fee_actual: self.compute_fee_actual(),
819+
fee_actual: compute_fee_actual(&self.fee_proposals_window, init.height),
820820
};
821821
self.validate_current_round_proposal(
822822
init,
@@ -1086,7 +1086,7 @@ impl ConsensusContext for SequencerConsensusContext {
10861086
.map(GasPrice)
10871087
.unwrap_or(self.l2_gas_price),
10881088
starknet_version: StarknetVersion::LATEST,
1089-
fee_actual: self.compute_fee_actual(),
1089+
fee_actual: compute_fee_actual(&self.fee_proposals_window, init.height),
10901090
};
10911091
self.validate_current_round_proposal(
10921092
init,

crates/apollo_consensus_orchestrator/src/snip35/test.rs

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use std::collections::BTreeMap;
22

3+
use rand::{Rng, SeedableRng};
4+
use rand_chacha::ChaCha8Rng;
35
use rstest::rstest;
46
use starknet_api::block::{BlockNumber, GasPrice};
57

@@ -8,6 +10,11 @@ use crate::snip35::{
810
compute_fee_proposal,
911
compute_fee_target,
1012
FeeProposalInfo,
13+
FEE_PROPOSAL_MARGIN_PPT,
14+
ORACLE_L2_GAS_FLOOR_MAX_FRI,
15+
ORACLE_L2_GAS_FLOOR_MIN_FRI,
16+
PPT_DENOMINATOR,
17+
TARGET_ATTO_USD_PER_L2_GAS,
1118
};
1219

1320
#[test]
@@ -149,8 +156,8 @@ fn test_compute_fee_proposal(
149156
#[test]
150157
fn test_compute_fee_actual_u128_max_does_not_overflow() {
151158
// Naive (a+b)/2 would overflow when a and b are near u128::MAX.
152-
let proposals = vec![GasPrice(u128::MAX); 10];
153-
assert_eq!(compute_fee_actual(&proposals, 10), Some(GasPrice(u128::MAX)));
159+
let window = window_from((0u64..10).map(|h| (h, Some(GasPrice(u128::MAX)))));
160+
assert_eq!(compute_fee_actual(&window, BlockNumber(10)), Some(GasPrice(u128::MAX)));
154161
}
155162

156163
#[test]
@@ -183,7 +190,62 @@ fn test_compute_fee_target_monotonic_in_strk_price() {
183190
#[test]
184191
fn test_compute_fee_actual_lone_adversary_cannot_skew_median() {
185192
// With 9 honest values and 1 outlier, median resists the adversary.
186-
let mut window = vec![GasPrice(1_000_000); 9];
187-
window.push(GasPrice(u128::MAX / 2));
188-
assert_eq!(compute_fee_actual(&window, 10), Some(GasPrice(1_000_000)));
193+
let window = window_from(
194+
(0u64..9)
195+
.map(|h| (h, Some(GasPrice(1_000_000))))
196+
.chain(std::iter::once((9u64, Some(GasPrice(u128::MAX / 2))))),
197+
);
198+
assert_eq!(compute_fee_actual(&window, BlockNumber(10)), Some(GasPrice(1_000_000)));
199+
}
200+
201+
/// The validator's accept predicate. Must stay in sync with
202+
/// `validate_proposal::is_proposal_init_valid` SNIP-35 bounds check.
203+
fn validator_accepts(fee_actual: GasPrice, fee_proposal: GasPrice, margin_ppt: u128) -> bool {
204+
let lower = fee_actual.0.saturating_mul(PPT_DENOMINATOR) / (PPT_DENOMINATOR + margin_ppt);
205+
let upper = fee_actual.0.saturating_mul(PPT_DENOMINATOR + margin_ppt) / PPT_DENOMINATOR;
206+
fee_proposal.0 >= lower && fee_proposal.0 <= upper
207+
}
208+
209+
#[test]
210+
fn test_malicious_high_fee_proposal_rejected() {
211+
// Upper bound for fee_actual=1_000_000 with margin=2ppt is 1_002_000.
212+
let fee_actual = GasPrice(1_000_000);
213+
assert!(validator_accepts(fee_actual, GasPrice(1_002_000), 2));
214+
for proposal in [1_002_001u128, 1_003_000, 2_000_000, u128::MAX] {
215+
assert!(!validator_accepts(fee_actual, GasPrice(proposal), 2), "accepted {proposal}");
216+
}
217+
}
218+
219+
#[test]
220+
fn test_malicious_low_fee_proposal_rejected() {
221+
// Lower bound for fee_actual=1_000_000 with margin=2ppt is 998_003.
222+
let fee_actual = GasPrice(1_000_000);
223+
assert!(validator_accepts(fee_actual, GasPrice(998_003), 2));
224+
for proposal in [998_002u128, 500_000, 1, 0] {
225+
assert!(!validator_accepts(fee_actual, GasPrice(proposal), 2), "accepted {proposal}");
226+
}
227+
}
228+
229+
#[test]
230+
fn test_honest_proposer_always_passes_validation_fuzzed() {
231+
// Consensus safety: whatever compute_fee_proposal produces, the validator accepts.
232+
let mut rng = ChaCha8Rng::seed_from_u64(0xDEADBEEF);
233+
for _ in 0..10_000 {
234+
let fee_actual_value = rng.gen_range(1u128..1_000_000_000_000_000_000);
235+
let strk_usd_rate = rng.gen_range(1u128..2 * 10u128.pow(18));
236+
let fee_actual = GasPrice(fee_actual_value);
237+
let target = compute_fee_target(
238+
TARGET_ATTO_USD_PER_L2_GAS,
239+
strk_usd_rate,
240+
ORACLE_L2_GAS_FLOOR_MIN_FRI,
241+
ORACLE_L2_GAS_FLOOR_MAX_FRI,
242+
);
243+
let oracle_result = if rng.gen_bool(0.1) { None } else { Some(target) };
244+
let proposal = compute_fee_proposal(oracle_result, fee_actual, FEE_PROPOSAL_MARGIN_PPT);
245+
assert!(
246+
validator_accepts(fee_actual, proposal, FEE_PROPOSAL_MARGIN_PPT),
247+
"fee_actual={fee_actual_value} rate={strk_usd_rate} proposal={}",
248+
proposal.0
249+
);
250+
}
189251
}

0 commit comments

Comments
 (0)