Skip to content

Commit f24886f

Browse files
apollo_consensus_orchestrator: add SNIP-35 fee_market functions and tests
1 parent 6902537 commit f24886f

2 files changed

Lines changed: 205 additions & 0 deletions

File tree

crates/apollo_consensus_orchestrator/src/fee_market/mod.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ mod test;
2222
// in approximately 10 minutes.
2323
const MIN_GAS_PRICE_INCREASE_DENOMINATOR: u128 = 333;
2424

25+
/// Scale factor for 18-decimal fixed-point conversion (1 STRK = 10^18 FRI).
26+
const FRI_DECIMALS_SCALE: u128 = 10u128.pow(18);
27+
28+
/// Denominator for parts-per-thousand calculations in SNIP-35 fee_proposal bounds.
29+
pub(crate) const PPT_DENOMINATOR: u128 = 1000;
30+
2531
/// Fee market information for the next block.
2632
#[derive(Debug, Default, Serialize)]
2733
pub struct FeeMarketInfo {
@@ -136,3 +142,58 @@ pub fn calculate_next_base_gas_price(
136142
let adjusted_price = u128::try_from(adjusted_price_u256).unwrap_or(u128::MAX);
137143
GasPrice(max(adjusted_price, min_gas_price.0))
138144
}
145+
146+
/// Compute fee_actual from the last `window_size` fee_proposal values (SNIP-35).
147+
/// Returns the average of the two middle values, rounded down.
148+
/// Returns `None` if fewer than `window_size` proposals are available.
149+
pub fn compute_fee_actual(proposals: &[GasPrice], window_size: usize) -> Option<GasPrice> {
150+
if proposals.len() < window_size || window_size < 2 {
151+
return None;
152+
}
153+
let window = &proposals[proposals.len() - window_size..];
154+
let mut sorted: Vec<u128> = window.iter().map(|p| p.0).collect();
155+
sorted.sort();
156+
// Median = average of the two middle values, rounded down.
157+
// Use overflow-safe averaging: a + (b - a) / 2 (safe because sorted, so b >= a).
158+
let mid = window_size / 2;
159+
let median = sorted[mid - 1] + (sorted[mid] - sorted[mid - 1]) / 2;
160+
// Return None if median is zero (e.g., pre-SNIP-35 blocks with fee_proposal=0).
161+
// This triggers the l2_gas_price fallback in both proposer and validator paths.
162+
if median == 0 { None } else { Some(GasPrice(median)) }
163+
}
164+
165+
/// Compute the fee target from STRK/USD price and a USD cost target (SNIP-35).
166+
/// `target_atto_usd_per_l2_gas` is in atto-USD (18-decimal fixed-point).
167+
/// `strk_usd_rate` is the STRK/USD price with 18 decimals (from oracle).
168+
/// Result is in FRI, clamped to `[floor_min_fri, floor_max_fri]`.
169+
pub fn compute_fee_target(
170+
target_atto_usd_per_l2_gas: u128,
171+
strk_usd_rate: u128,
172+
floor_min_fri: u128,
173+
floor_max_fri: u128,
174+
) -> GasPrice {
175+
if strk_usd_rate == 0 {
176+
return GasPrice(floor_max_fri);
177+
}
178+
// floor_fri = target_atto_usd_per_l2_gas * 10^18 / strk_usd_rate
179+
let numerator = U256::from(target_atto_usd_per_l2_gas) * U256::from(FRI_DECIMALS_SCALE);
180+
let floor = numerator / U256::from(strk_usd_rate);
181+
let floor_u128 = u128::try_from(floor).unwrap_or(u128::MAX);
182+
GasPrice(floor_u128.clamp(floor_min_fri, floor_max_fri))
183+
}
184+
185+
/// Compute the fee_proposal an honest proposer should publish (SNIP-35).
186+
/// - If oracle failed (`fee_target` is `None`): freeze at `fee_actual`.
187+
/// - Otherwise: clamp `fee_target` to within +/-`margin_ppt` parts per thousand of `fee_actual`.
188+
pub fn compute_fee_proposal(
189+
fee_target: Option<GasPrice>,
190+
fee_actual: GasPrice,
191+
margin_ppt: u128,
192+
) -> GasPrice {
193+
let Some(fee_target) = fee_target else {
194+
return fee_actual;
195+
};
196+
let upper = fee_actual.0.saturating_mul(PPT_DENOMINATOR + margin_ppt) / PPT_DENOMINATOR;
197+
let lower = fee_actual.0.saturating_mul(PPT_DENOMINATOR) / (PPT_DENOMINATOR + margin_ppt);
198+
GasPrice(fee_target.0.clamp(lower, upper))
199+
}

crates/apollo_consensus_orchestrator/src/fee_market/test.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ use starknet_api::versioned_constants_logic::VersionedConstantsTrait;
77

88
use crate::fee_market::{
99
calculate_next_base_gas_price,
10+
compute_fee_actual,
11+
compute_fee_proposal,
12+
compute_fee_target,
1013
get_min_gas_price_for_height,
1114
MIN_GAS_PRICE_INCREASE_DENOMINATOR,
1215
};
@@ -214,3 +217,144 @@ fn test_calculate_with_price_close_to_minimum() {
214217
// When price is close to minimum, should cap at min_gas_price to avoid overshooting
215218
assert_eq!(result, min_gas_price);
216219
}
220+
221+
#[test]
222+
fn test_compute_fee_actual_with_10_identical_values() {
223+
let proposals: Vec<GasPrice> = vec![GasPrice(1000); 10];
224+
assert_eq!(compute_fee_actual(&proposals, 10), Some(GasPrice(1000)));
225+
}
226+
227+
#[test]
228+
fn test_compute_fee_actual_with_ascending_values() {
229+
let proposals: Vec<GasPrice> = (1..=10).map(|i| GasPrice(i * 100)).collect();
230+
// Sorted: 100,200,300,400,500,600,700,800,900,1000. Median = (500+600)/2 = 550.
231+
assert_eq!(compute_fee_actual(&proposals, 10), Some(GasPrice(550)));
232+
}
233+
234+
#[test]
235+
fn test_compute_fee_actual_with_descending_values() {
236+
let proposals: Vec<GasPrice> = (1..=10).rev().map(|i| GasPrice(i * 100)).collect();
237+
// Same sorted order, same median.
238+
assert_eq!(compute_fee_actual(&proposals, 10), Some(GasPrice(550)));
239+
}
240+
241+
#[test]
242+
fn test_compute_fee_actual_with_outliers() {
243+
let mut proposals: Vec<GasPrice> = vec![GasPrice(100); 8];
244+
proposals.push(GasPrice(1)); // Low outlier
245+
proposals.push(GasPrice(1_000_000)); // High outlier
246+
// Sorted: 1,100,100,100,100,100,100,100,100,1000000. Median = (100+100)/2 = 100.
247+
assert_eq!(compute_fee_actual(&proposals, 10), Some(GasPrice(100)));
248+
}
249+
250+
#[test]
251+
fn test_compute_fee_actual_fewer_than_window_returns_none() {
252+
let proposals: Vec<GasPrice> = vec![GasPrice(100); 9];
253+
assert_eq!(compute_fee_actual(&proposals, 10), None);
254+
}
255+
256+
#[test]
257+
fn test_compute_fee_actual_empty_returns_none() {
258+
assert_eq!(compute_fee_actual(&[], 10), None);
259+
}
260+
261+
#[test]
262+
fn test_compute_fee_actual_custom_window_size() {
263+
let proposals: Vec<GasPrice> = vec![GasPrice(200); 4];
264+
assert_eq!(compute_fee_actual(&proposals, 4), Some(GasPrice(200)));
265+
assert_eq!(compute_fee_actual(&proposals, 5), None);
266+
}
267+
268+
#[test]
269+
fn test_compute_fee_actual_zero_median_returns_none() {
270+
// All zeros → median is 0 → returns None (triggers l2_gas_price fallback).
271+
let proposals: Vec<GasPrice> = vec![GasPrice(0); 10];
272+
assert_eq!(compute_fee_actual(&proposals, 10), None);
273+
}
274+
275+
#[test]
276+
fn test_compute_fee_actual_uses_only_last_window_entries() {
277+
// 12 entries: first 2 are outliers, last 10 are 500.
278+
let mut proposals: Vec<GasPrice> = vec![GasPrice(999_999); 2];
279+
proposals.extend(vec![GasPrice(500); 10]);
280+
// With window_size=10, the outliers should be ignored (only last 10 used).
281+
assert_eq!(compute_fee_actual(&proposals, 10), Some(GasPrice(500)));
282+
}
283+
284+
#[test]
285+
fn test_compute_fee_target_normal() {
286+
// Target: $3e-9/gas = 3_000_000_000 atto-USD. STRK at $0.50 = 500_000_000_000_000_000.
287+
// floor = 3_000_000_000 * 10^18 / 500_000_000_000_000_000 = 6_000_000_000.
288+
let target = compute_fee_target(3_000_000_000, 500_000_000_000_000_000, 0, u128::MAX);
289+
assert_eq!(target, GasPrice(6_000_000_000));
290+
}
291+
292+
#[test]
293+
fn test_compute_fee_target_clamp_min() {
294+
let target = compute_fee_target(1, 10u128.pow(18), 100, u128::MAX);
295+
// floor = 1 * 10^18 / 10^18 = 1, but clamped to min 100.
296+
assert_eq!(target, GasPrice(100));
297+
}
298+
299+
#[test]
300+
fn test_compute_fee_target_clamp_max() {
301+
// Very low STRK price → very high floor, clamped to max.
302+
let target = compute_fee_target(10u128.pow(18), 1, 0, 1000);
303+
assert_eq!(target, GasPrice(1000));
304+
}
305+
306+
#[test]
307+
fn test_compute_fee_target_zero_rate() {
308+
let target = compute_fee_target(100, 0, 0, 999);
309+
assert_eq!(target, GasPrice(999));
310+
}
311+
312+
#[test]
313+
fn test_compute_fee_proposal_oracle_failure_freezes() {
314+
let proposal = compute_fee_proposal(None, GasPrice(1000), 2);
315+
assert_eq!(proposal, GasPrice(1000));
316+
}
317+
318+
#[test]
319+
fn test_compute_fee_proposal_target_above_actual() {
320+
// fee_target=2000, fee_actual=1000, margin=2ppt. Upper bound = 1000*1002/1000 = 1002.
321+
let proposal = compute_fee_proposal(Some(GasPrice(2000)), GasPrice(1000), 2);
322+
assert_eq!(proposal, GasPrice(1002));
323+
}
324+
325+
#[test]
326+
fn test_compute_fee_proposal_target_below_actual() {
327+
// fee_target=500, fee_actual=1000, margin=2ppt. Lower bound = 1000*1000/1002 = 998.
328+
let proposal = compute_fee_proposal(Some(GasPrice(500)), GasPrice(1000), 2);
329+
assert_eq!(proposal, GasPrice(998));
330+
}
331+
332+
#[test]
333+
fn test_compute_fee_proposal_target_within_bounds() {
334+
// fee_target=1001, fee_actual=1000, margin=2ppt. 1001 is within [998, 1002].
335+
let proposal = compute_fee_proposal(Some(GasPrice(1001)), GasPrice(1000), 2);
336+
assert_eq!(proposal, GasPrice(1001));
337+
}
338+
339+
#[test]
340+
fn test_compute_fee_proposal_fee_actual_zero_clamps_to_zero() {
341+
// When fee_actual=0, both bounds are 0, so fee_proposal is always 0.
342+
let proposal = compute_fee_proposal(Some(GasPrice(1000)), GasPrice(0), 2);
343+
assert_eq!(proposal, GasPrice(0));
344+
}
345+
346+
#[test]
347+
fn test_compute_fee_actual_window_size_below_minimum_returns_none() {
348+
let proposals: Vec<GasPrice> = vec![GasPrice(100); 10];
349+
assert_eq!(compute_fee_actual(&proposals, 0), None);
350+
assert_eq!(compute_fee_actual(&proposals, 1), None);
351+
}
352+
353+
#[test]
354+
fn test_compute_fee_proposal_custom_margin() {
355+
// margin=10ppt (1%). fee_actual=10000. Upper=10000*1010/1000=10100. Lower=10000*1000/1010=9900.
356+
let proposal_up = compute_fee_proposal(Some(GasPrice(99999)), GasPrice(10000), 10);
357+
assert_eq!(proposal_up, GasPrice(10100));
358+
let proposal_down = compute_fee_proposal(Some(GasPrice(1)), GasPrice(10000), 10);
359+
assert_eq!(proposal_down, GasPrice(9900));
360+
}

0 commit comments

Comments
 (0)