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
Open
apollo_consensus_orchestrator,apollo_l1_gas_price: bound the per-block eth to fri rate change#15052asaf-sw wants to merge 1 commit into
asaf-sw wants to merge 1 commit into
Conversation
asaf-sw
force-pushed
the
asaf/l1-oracle-10-rate-change-bound-0144
branch
from
August 27, 2026 15:05
a31132f to
e1a00e8
Compare
…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
force-pushed
the
asaf/l1-oracle-10-rate-change-bound-0144
branch
from
August 30, 2026 07:02
e1a00e8 to
830e6f4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ports #14973 onto
main-v0.14.4. A fresh oracle rate is now clamped into a band ofmax_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_configleaves: 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_changeinapollo_consensus_orchestrator/src/utils.rsclamps the oracle rate into the band and incrementsCONSENSUS_ETH_TO_FRI_RATE_CLAMPED, with a warning log when it bites.max_eth_to_fri_rate_change_pptinContextDynamicConfig, default 50. Wired intoconfig_schema.jsonand 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 withignore_default_values = true.initialize_fee_proposals_windowrenamed toinitialize_from_committed_blocks, since it now bootstraps two things. It additionally seedsprevious_proposal_initfrom blockstart_height - 1, reusing the existingprevious_proposal_init_from_block_headerhelper. Sole caller isapollo_consensus_manager.committed_block_header. It retries the same height in place rather than re-queueing to the back of aVecDeque. Equivalent, because heights commit in order, and it issues strictly fewer reads.get_panel_consensus_eth_to_fri_rate_clampedadded, modelled on the SNIP-35 panel and wired intoget_consensus_row().dev_grafana.jsonregenerated. No alert added, deliberately: an alert here would need a per-deployment threshold override key in every environment, which is a separate decision.tokiogains thetest-utildev-dependency feature, required forstart_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_componentspath added by #15006. That route needs a newBatcherRequest/BatcherResponsevariant, a newBatcherClienttrait method, and local plus remote server wiring, becauseget_partial_block_hash_componentssits on the batcher-internalBatcherStorageReaderTraitand its only consumer is the batcher's own privateget_block_info.State sync needs none of that:
SyncBlock.block_header_without_hashalready carriesl1_gas_priceandl1_data_gas_priceasGasPricePerToken, which holds bothprice_in_weiandprice_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)copiesl1_gas_price_weiandl1_gas_price_friverbatim from the acceptedProposalInit, andcalculate_eth_to_fri_ratedivides one by the other. Butvalidate_proposalchecks the two legs independently throughwithin_marginatl1_gas_price_margin_percent = 10, with nothing checking their ratio. A proposer publishing0.9 * wand1.1 * fpasses 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 onclamp_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_pptcurrently lives inContextDynamicConfig, 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_paramdescription and schema now state that the value must match network-wide. Moving it intoVersionedConstantsnext tol1_gas_price_margin_percentwould make that provable rather than documented.Verification
rust_fmt, clippy with-D warnings, and nextest acrossapollo_consensus_orchestrator,apollo_consensus_manager,apollo_consensus_orchestrator_config,apollo_l1_gas_priceandapollo_dashboardunder--all-features(358 tests), plusapollo_node_configandapollo_deploymentswithout--all-features(26 tests).default_config_file_is_up_to_dateanddeployment_files_are_up_to_dateboth pass, confirming the schema and app config entries are correct.