Skip to content

apollo_l1_gas_price_config,apollo_deployments,apollo_node: add ExchangeRateOracleSource - #14995

Open
asaf-sw wants to merge 1 commit into
asaf/l1-oracle-b2-nest-configfrom
asaf/l1-oracle-b3-oracle-source
Open

apollo_l1_gas_price_config,apollo_deployments,apollo_node: add ExchangeRateOracleSource#14995
asaf-sw wants to merge 1 commit into
asaf/l1-oracle-b2-nest-configfrom
asaf/l1-oracle-b3-oracle-source

Conversation

@asaf-sw

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

Copy link
Copy Markdown
Contributor

Adds ExchangeRateOracleSource (Http default, Chainlink) and one *_oracle_source field per feed, so a feed moves off the HTTP oracle by editing a config key rather than by deploying code. The two feeds are selected independently so they can be migrated one at a time. Also rewrites the micro-unit bound descriptions, which were written for ETH/USD but served under strk_usd too, where the worked example disagreed with the shipped default by five orders of magnitude.

B3 of the split of #14944. 2 new parameters.

  • Deferred: both feeds default to Http and nothing reads either field. B4 is the first PR whose behavior depends on them, so merging this changes nothing at runtime.
  • Scheduled for deletion: the enum, both fields, both ser_params and the three tests all go in C1 with the HTTP oracle. The enum carries a TODO(asaf-sw) naming that removal, which is why the switch is deliberately thin. The description rewrite is the part that survives.
  • Worth a look: unrecognized_oracle_source_fails_to_load pins that the enum takes no serde(rename_all), so a lowercase "chainlink" cannot silently fall back to Http and produce a node that looks migrated.

Detailed Summary for AI Bots

Stacked on #14994. Part of the L1 price oracle replacement split of #14942.

What

ExchangeRateOracleSource (Http default, Chainlink) and the two *_oracle_source fields that carry it, one per feed, so a feed moves off the HTTP oracle by editing a config key rather than by deploying code. 2 new parameters, no existing parameter's value or privacy touched.

Both feeds default to Http, and nothing reads either field yet: B4 builds each feed's client from its source and is the first PR whose behavior depends on these values. Merging this changes nothing at runtime.

The two feeds are selected independently so they can be migrated one at a time, and so the second flip can wait on a week of evidence from the first.

Every line of the switch has a deletion date

The enum, both fields, both ser_params and the three tests below all go away in C1, together with the HTTP oracle they exist to switch away from. The enum carries a TODO(asaf-sw) naming that removal. Nothing here is built to last, so it is deliberately thin: an enum, two pub fields, a Default, and no accessor, no helper, no validation beyond what serde already rejects.

What survives C1 is the description rewrite in the second half of this PR, which is about micro units rather than about sources.

Chainlink is mainnet-only

The feed addresses B2 shipped are the Chainlink proxies on Starknet mainnet, in every environment, because no other network has the feeds. That restriction is documented where an operator flipping the switch will read it: both *_oracle_source descriptions, and therefore both entries in config_schema.json, state that Chainlink is mainnet-only until the feeds are deployed elsewhere.

The same descriptions state the other precondition: Chainlink requires a batcher client, and selecting it on a service that has none is a startup failure rather than a fallback to Http. B4 is what enforces that, naming every offending key in one startup instead of one per restart. Every shipped topology gives the L1 service a batcher client, so it is reachable only by misconfiguration.

Flipping a feed also waits on #14973's rate-of-change bound and on the cap chain, for the reasons set out in #14944; those are gates on using the switch, not on merging it.

The rename_all test is the one that has to be here

unrecognized_oracle_source_fails_to_load pins that the enum takes no serde(rename_all), so the variants are matched exactly as written in Rust. Without it, a lowercase "chainlink" deserializes to nothing, falls back to the Http default, and produces a node that looks migrated while reading the same HTTP API it read yesterday. Config loading does reject the unknown value today; the test is what keeps a later convenience rename_all from turning that rejection into a silent default.

It earns its place precisely because the window is short. While both sources exist a wrong flip is detectable by comparing the two, but once the HTTP source is gone in C1 there is no comparison left to make, so the only chance to notice is now.

The four cases cover the casings an operator plausibly types (chainlink, CHAINLINK), a feed name mistaken for a source (eth_to_strk), and an unknown source (Coinbase), each against both param paths.

The bound descriptions were pair-specific and served to every pair

QuotedRateBoundsConfig::dump() produces the descriptions for both eth_usd and strk_usd, so its worked conversion, written for ETH/USD, was served under both keys. The schema shipped rate_bounds_config.strk_usd.minimum_micro_units described as "a value of 20000000 on ETH/USD means $20" against an actual default of 100, which is $0.0001. An operator reading the schema had a worked example that disagreed with the value next to it by five orders of magnitude.

Both descriptions are now pair-agnostic and keep a worked conversion, since the schema is all an operator has:

  • minimum_micro_units: "Lowest accepted price for this pair, in micro units (1e-6) of the pair's quote currency, so a value of 20000000 means 20 units of the quote currency, and a value of 100 means 0.0001 units."
  • maximum_micro_units: "Highest accepted price for this pair, in micro units (1e-6) of the pair's quote currency, so a value of 50000000000 means 50,000 units of the quote currency."

The minimum carries both worked examples because the two defaults it is served for are 20000000 and 100; one example alone leaves whichever key it was not written for looking wrong. Micro units reached the schema for the first time in B2 and were explained only in a Rust doc comment before that, where being wrong by 10^6 either disables the guard or rejects every reading.

DerivedRateBoundsConfig is untouched: it serves eth_to_fri only, so its ETH/STRK examples are correct for the one key they reach.

Testing

9 new cases, 32 in the crate. default_oracle_sources_are_http on the Default, oracle_sources_round_trip_through_serialize_config over all four combinations of the two feeds, and unrecognized_oracle_source_fails_to_load over four rejected values against both params. The round-trip test shares its dump-to-flat-map helper with B2's provider_config_round_trips_through_serialize_config, which is what makes the rejection test meaningful: Chainlink loads through the same path the bad values are rejected on, so the failures are about the value and not about the harness.

default_config_file_is_up_to_date and deployment_files_are_up_to_date are the staleness gates on the regenerated files, and both pass. The 2 new keys were added to both base app configs by hand, as in B2, before regenerating.

🤖 Generated with Claude Code

…geRateOracleSource

Adds `ExchangeRateOracleSource` (`Http` default, `Chainlink`) and the two
`*_oracle_source` fields that carry it, one per feed, so a feed moves off the
HTTP oracle by a config edit rather than a code deploy. Both feeds default to
`Http` and nothing reads either field yet: B4 builds each feed's client from its
source. The enum, both fields and both params are deleted with the HTTP oracle
in C1, as the `TODO(asaf-sw)` on the enum records.

Both param descriptions state that `Chainlink` requires a batcher client, and
that it is mainnet-only until the feeds are deployed off mainnet.

Also makes the shared `QuotedRateBoundsConfig` descriptions pair-agnostic. They
are served for both `eth_usd` and `strk_usd`, so the ETH/USD worked conversion
was shipped against `strk_usd`'s default of 100, which is $0.0001 rather than
$20. Every description keeps a worked micro-unit conversion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@reviewable-StarkWare

Copy link
Copy Markdown

This change is Reviewable

asaf-sw commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@github-actions

Copy link
Copy Markdown

Artifacts upload workflows:

@asaf-sw

asaf-sw commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Migrated review comment from #14944, closed in favor of this split.

@matanl-starkware wrote on crates/apollo_deployments/resources/app_configs/l1_gas_price_provider_config.json line 5, on 1000000000000 (original thread):

These values are really unreadable.
Consider converting the units, dropping the micros away (using floats is preferable IMO).

This PR owns the operator-facing descriptions that spell those values out ("a value of 50000000000 means 50,000 units of the quote currency, and a value of 10000000 means 10 units"). The minimum_micro_units / maximum_micro_units fields themselves are defined in #14982, where the same comment is also copied.

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.

2 participants