Astria Oracle
Description
The Astria Oracle is responsible for providing the protocol with the current price feeds. It is a critical component as the protocol relies on the Oracle to determine the value of the assets.
The validator nodes are responsible for providing the Oracle with the current price feeds. The validators have sidecars that are responsible for fetching the price of feeds from external sources and submitting them, voting on these prices until consensus. If two thirds of the validators agree on the voting of oracle, the price is considered valid, and it will be stored in the state.
Components
The Astria Oracle have two components:
OracleComponent Oracle stores and manages currency-pair data, including mappings from pairs to IDs, states, and price quotes.
MarketMapComponent MarketMap stores and manages data about multiple markets, including their tickers, decimal precision, provider configurations, and metadata.
Process
The Oracle component goes through the following process:
init_chainsetsvote_extensions_enable_height(this is the height where the voting extension is enabled) and sets up MarketMapComponent and OracleComponent.prepare_proposalmakesextended_commit_infofrom the last commit and attaches it to the proposal.process_proposalvalidates theextended_commit_infoin the proposal.finalize_blockcallsapply_prices_from_vote_extensionsto write the price of pairs to the state.
Vote extensions
Vote extensions in the network allow validators to fetch off-chain data and include it in their votes, which are then broadcasted and committed on-chain. This process is managed by vote extension handlers. Since vote extensions are only available to other validators at the next block height, oracle data is always one height behind. Additionally, each validator maintains a local view of vote extensions, meaning there is no single canonical set. To ensure determinism, the network relies on the next proposer's local view as the authoritative version.
The oracle data can only be committed to the chain if more than 2/3 of the validator power submits valid vote extensions. If fewer than 2/3 of validators submit valid vote extensions, the chain does not halt. In this case, blocks will be finalized but no new oracle data won't be published for that block.
Handler
extend_voteretrieves price information from the sidecar oracle client and transforms it into a vote extension. It returns an empty vote extension if price retrieval fails.transform_oracle_service_pricestransforms oracle service prices by converting currency pairs to their corresponding IDs and filtering out unknown pairs.verify_vote_extensionvalidates oracle vote extension by checking price count and individual price lengths.
ProposalHandler
prepare_proposalprepares a proposal by validating and pruning vote extensions from the previous block.validate_proposalvalidates a proposed block'sextended_commit_infoagainst the last commit and state rules.get_id_to_currency_pairretrieves a mapping of currency-pair IDs to their corresponding currency-pair information from the state.validate_id_to_currency_pair_mappingvalidates that the currency-pair mapping is consistent with expected values, checking for missing, extra, or mismatched pairs.validate_vote_extensionsvalidates vote extensions by checking for repeated voters, signature validity, voting power, and other consensus-related constraints. Two thirds of validators must agree on the price for it to be considered valid.validate_extended_commit_against_last_commitcompares the current extended commit info with the last commit to ensure consistency in round, vote, and validator information.apply_prices_from_vote_extensionsapplies prices from vote extensions to the state, storing quote prices for each currency pair.
Oracle-price calculation
The current system uses a median-based approach to calculate prices from validators. Instead of relying on weighted pricing, where some validators may have more influence, the median simply takes the middle value from a sorted list. This makes it naturally resistant to extreme outliers. Even if a validator is compromised and proposes an incorrect price, it will not significantly affect the result as long as the majority of validators provide reasonable values.
For example, if validators report 3.1, 3.2, 3.2, 3.1, 3,000, and 3, the median would still be 3.2 despite the outlier (3,000). As more validators participate, the effect of a single malicious actor becomes even smaller. While the median approach is not perfect — it could still be manipulated if enough validators collude — it is a straightforward method to ensure reliable pricing in most situations. Adding extra checks, like outlier detection, could make it even stronger.
Oracle client timeout and retry logic
The new_oracle_client function uses an exponential back-off retry mechanism during the initial connection attempt to the oracle sidecar. The retry duration starts at 100 ms and doubles with each attempt until a maximum delay of 10 seconds is reached. The total retry time sums to approximately five minutes. Although this maximum delay is significant compared to the two-second block-confirmation time, it only affects the initial start-up process. If the connection fails after all retries, the sequencer exits with an error.
/// Returns a new Connect oracle client or `Ok(None)` if `config.no_oracle` is true.
///
/// If `config.no_oracle` is false, returns `Ok(Some(...))` as soon as a successful response is
/// received from the oracle sidecar, or returns `Err` after a fixed number of failed re-attempts
/// (roughly equivalent to 5 minutes total).
#[instrument(skip_all, err)]
async fn new_oracle_client(config: &Config) -> Result<Option<OracleClient<Channel>>> {
if config.no_oracle {
return Ok(None);
}
let uri: Uri = config
.oracle_grpc_addr
.parse()
.context("failed parsing oracle grpc address as Uri")?;
! let endpoint = Endpoint::from(uri.clone()).timeout(Duration::from_millis(
! config.oracle_client_timeout_milliseconds,
! ));
let retry_config = tryhard::RetryFutureConfig::new(MAX_RETRIES_TO_CONNECT_TO_ORACLE_SIDECAR)
! .exponential_backoff(Duration::from_millis(100))
! .max_delay(Duration::from_secs(10))
.on_retry(
|attempt, next_delay: Option<Duration>, error: &eyre::Report| {
let wait_duration = next_delay
.map(humantime::format_duration)
.map(tracing::field::display);
warn!(
error = error.as_ref() as &dyn std::error::Error,
attempt,
wait_duration,
"failed to query oracle sidecar; retrying after backoff",
);
async {}
},
);In normal operations, the oracle client applies a configurable timeout (ASTRIA_SEQUENCER_ORACLE_CLIENT_TIMEOUT_MILLISECONDS, default is 1,000 ms) to each RPC request. If a request times out, the sequencer does not retry; instead, it provides an empty vote extension to ensure the network remains operational. This design prioritizes liveness over data accuracy during transient oracle-connectivity issues.
impl Handler {
pub(crate) fn new(oracle_client: Option<OracleClient<Channel>>) -> Self {
Self {
oracle_client,
}
}
pub(crate) async fn extend_vote<S: StateReadExt>(
&mut self,
state: &S,
) -> Result<abci::response::ExtendVote> {
let Some(oracle_client) = self.oracle_client.as_mut() else {
// we allow validators to *not* use the oracle sidecar currently,
// so this will get converted to an empty vote extension when bubbled up.
//
// however, if >1/3 of validators are not using the oracle, the prices will not update.
bail!("oracle client not set")
};
! // if we fail to get prices within the timeout duration, we will return an empty vote
! // extension to ensure liveness.
! let rsp = match oracle_client.prices(QueryPricesRequest {}).await {
! Ok(rsp) => rsp.into_inner(),
! Err(e) => {
! bail!("failed to get prices from oracle sidecar: {e:#}",);
! }
! };Attack surface
Malicious validators and external sources can provide false prices to the Oracle.