Skip to content

apollo_consensus_orchestrator,apollo_l1_gas_price: bound the per-block eth to fri rate change - #15052

Open
asaf-sw wants to merge 1 commit into
main-v0.14.4from
asaf/l1-oracle-10-rate-change-bound-0144
Open

apollo_consensus_orchestrator,apollo_l1_gas_price: bound the per-block eth to fri rate change#15052
asaf-sw wants to merge 1 commit into
main-v0.14.4from
asaf/l1-oracle-10-rate-change-bound-0144

Conversation

@asaf-sw

@asaf-sw asaf-sw commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Ports #14973 onto main-v0.14.4. A fresh oracle rate is now clamped into a band of max_eth_to_fri_rate_change_ppt (default 50, i.e. 5%) around the rate implied by the previous block's recorded wei and fri prices.

This closes the gap the absolute rate_bounds_config leaves: those bounds ($20k-50k per ETH, $0.0001-10 per STRK, 10k-1M STRK per ETH) are wide enough to admit a manipulated but plausible feed value. #14944 names this bound as a precondition for flipping a feed source.

It also closes the startup gap the original PR left open. Previously there was no anchor at startup, so the first block after a restart went unclamped. Genesis is now the only height without one.

Please read the "Known limitation" section below before approving. The bound on how far the band's center can move per accepted proposal is 1.222x, not 5%, and closing that needs a coordinated activation height.


Detailed Summary for AI Bots

What changed

  • clamp_eth_to_fri_rate_change in apollo_consensus_orchestrator/src/utils.rs clamps the oracle rate into the band and increments CONSENSUS_ETH_TO_FRI_RATE_CLAMPED, with a warning log when it bites.
  • New config key max_eth_to_fri_rate_change_ppt in ContextDynamicConfig, default 50. Wired into config_schema.json and both consensus manager app configs (consensus_manager_config.json, replacer_consensus_manager_config.json). A key present in the schema but missing from the app configs crash-loops every node, since the node loads configs with ignore_default_values = true.
  • initialize_fee_proposals_window renamed to initialize_from_committed_blocks, since it now bootstraps two things. It additionally seeds previous_proposal_init from block start_height - 1, reusing the existing previous_proposal_init_from_block_header helper. Sole caller is apollo_consensus_manager.
  • The retry-until-state-sync-is-ready fetch is extracted into committed_block_header. It retries the same height in place rather than re-queueing to the back of a VecDeque. Equivalent, because heights commit in order, and it issues strictly fewer reads.
  • Dashboard panel get_panel_consensus_eth_to_fri_rate_clamped added, modelled on the SNIP-35 panel and wired into get_consensus_row(). dev_grafana.json regenerated. No alert added, deliberately: an alert here would need a per-deployment threshold override key in every environment, which is a separate decision.
  • tokio gains the test-util dev-dependency feature, required for start_paused = true. Without it the crate's tests do not compile standalone, which is the shape of CI's per-package jobs.

Anchor source: state sync, not the batcher

The original plan was to read the previous block's prices from the batcher via the get_partial_block_hash_components path added by #15006. That route needs a new BatcherRequest/BatcherResponse variant, a new BatcherClient trait method, and local plus remote server wiring, because get_partial_block_hash_components sits on the batcher-internal BatcherStorageReaderTrait and its only consumer is the batcher's own private get_block_info.

State sync needs none of that: SyncBlock.block_header_without_hash already carries l1_gas_price and l1_data_gas_price as GasPricePerToken, which holds both price_in_wei and price_in_fri, and the orchestrator already reads blocks from that client in the same startup function. Same anchor pair, zero new APIs.

Known limitation, and the follow-up it needs

The delivered guarantee is that the band bounds what an honest node publishes, not how far the band's center can move.

PreviousProposalInitInfo::from(&init) copies l1_gas_price_wei and l1_gas_price_fri verbatim from the accepted ProposalInit, and calculate_eth_to_fri_rate divides one by the other. But validate_proposal checks the two legs independently through within_margin at l1_gas_price_margin_percent = 10, with nothing checking their ratio. A proposer publishing 0.9 * w and 1.1 * f passes both checks, so an accepted proposal can move every node's band center by (1 + margin) / (1 - margin) = 1.222x. Since the pull-back is only 5% per block, with roughly one proposal slot in four the center ratchets: 1.222 * 0.95^3 = 1.047 > 1.

Closing this means rejecting a proposal whose implied fri/wei ratio falls outside the validator's own band. That is a new consensus validity rule: its tolerance must be at least 2 * max_eth_to_fri_rate_change_ppt, because two honest nodes clamped into the same band can legitimately differ by that much, and it has to activate at a coordinated height or the first node to ship it forks. TODO(Asaf) is in place on clamp_eth_to_fri_rate_change, and the docs state the delivered guarantee rather than the intended one.

Related open choice: max_eth_to_fri_rate_change_ppt currently lives in ContextDynamicConfig, so it is per-node and operator-tunable. A spread above roughly 100 ppt between two honest nodes can reject an honest proposal on a large oracle move. The field doc, ser_param description and schema now state that the value must match network-wide. Moving it into VersionedConstants next to l1_gas_price_margin_percent would make that provable rather than documented.

Verification

rust_fmt, clippy with -D warnings, and nextest across apollo_consensus_orchestrator, apollo_consensus_manager, apollo_consensus_orchestrator_config, apollo_l1_gas_price and apollo_dashboard under --all-features (358 tests), plus apollo_node_config and apollo_deployments without --all-features (26 tests). default_config_file_is_up_to_date and deployment_files_are_up_to_date both pass, confirming the schema and app config entries are correct.

@reviewable-StarkWare

Copy link
Copy Markdown

This change is Reviewable

@asaf-sw
asaf-sw force-pushed the asaf/l1-oracle-10-rate-change-bound-0144 branch from a31132f to e1a00e8 Compare August 27, 2026 15:05
…k eth to fri rate change

The Chainlink oracle guards each reading with absolute sanity bounds, but those are
deliberately wide (the STRK/USD band spans five decades) because their job is wrong-feed
detection. A manipulated but plausible feed value passes them. A bound relative to the
previously accepted rate is what catches that, and #14944 names it as a precondition before
any feed is switched to the on-chain oracle.

The bound lives in the orchestrator, not in the Chainlink client. A bound anchored to the
client's own last read is node-local history: two validators with different read histories
would accept and reject different proposals. Anchoring instead on the rate implied by the
previous block's recorded wei and fri prices (calculate_eth_to_fri_rate) makes the bound a
function of the block header and of config alone, so every validator derives the same band
and maps a given oracle reading to the same rate. It also covers both oracle sources, HTTP
and Chainlink, which a bound inside the Chainlink client would not.

A fresh rate outside the band is clamped to the band's edge rather than rejected, following
the shape of the per-block L2 gas price clamp: clamping bounds per-block damage and forces a
manipulation to be sustained across many blocks to move the price far.

max_eth_to_fri_rate_change_ppt defaults to 50 (5% per block). The ETH and STRK feeds update
on deviation thresholds of roughly 0.5% and 1%, so the ratio steps by at most ~1.5% between
consecutive blocks and a normal feed update never trips the bound. On the other side, one
manipulated reading moves the rate by at most 5%, and doubling it requires holding the feed
for about 15 consecutive blocks (~40 seconds at mainnet block times), long enough for the
oracle staleness and guard alerts to fire. A genuine sharp move is tracked within a few
blocks.

That bounds a manipulated feed, not a byzantine proposer. is_proposal_init_valid checks the
proposed wei and fri prices against l1_gas_price_margin_percent one at a time and never as a
ratio, so an accepted proposal can imply a band center (1 + margin) / (1 - margin), 1.22 at
the current margin, away from the rate the validator itself holds, and the honest nodes then
publish the decayed value for the next few blocks. Closing that needs a check on the rate a
proposal implies, which is a new consensus validity rule; it is a TODO on
clamp_eth_to_fri_rate_change, and the doc comments state the delivered bound rather than the
intended one.

max_eth_to_fri_rate_change_ppt is the whole strength of the bound, so validate_dynamic_config
rejects a value outside 1..1000: zero pins the rate at the previous block's forever, and a
thousand or more puts the band's lower edge at zero and leaves the bound one-sided.
ConfigManager validates on every dynamic-config update, so such a value is rejected instead
of quietly disabling the bound.

A restarted node held no previous block, so it would have proposed one unclamped rate before
its first decision. initialize_fee_proposals_window is now
initialize_from_committed_blocks: on top of the fee window it seeds previous_proposal_init
from block start_height - 1 in local state_sync storage, reusing
previous_proposal_init_from_block_header. Genesis is the only height left with no anchor.

Clamping increments consensus_eth_to_fri_rate_clamped and logs a warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@asaf-sw
asaf-sw force-pushed the asaf/l1-oracle-10-rate-change-bound-0144 branch from e1a00e8 to 830e6f4 Compare August 30, 2026 07:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants