Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ fn parse_source_from_issuer(issuer: &[u8]) -> Option<(u32, u64)> {

fn callback_gas_limit(source_type: u32) -> Result<u64, String> {
match source_type {
source_types::BLOCKCHAIN => Ok(CALLBACK_GAS_LIMIT),
source_types::BLOCKCHAIN | source_types::PRICE_FEED => Ok(CALLBACK_GAS_LIMIT),
_ => Err(format!("Unsupported oracle source type: {source_type}")),
}
}
Expand Down Expand Up @@ -190,6 +190,17 @@ fn construct_unsupported_oracle_batch_transaction(
mod tests {
use super::*;
use alloy_consensus::Transaction;
use alloy_primitives::I256;

sol! {
struct PricePayloadForTest {
uint256 feedId;
uint64 roundId;
uint64 resolvedAt;
uint8 decimals;
int256 price;
}
}

fn wrapped_jwk(nonce: u128, position: U256, payload: &[u8]) -> JWKStruct {
JWKStruct {
Expand Down Expand Up @@ -242,14 +253,39 @@ mod tests {
assert_eq!(call.callbackGasLimits, vec![U256::from(CALLBACK_GAS_LIMIT)]);
}

#[test]
fn preserves_price_feed_coordinates_payload_and_callback_gas() {
let payload = PricePayloadForTest {
feedId: U256::from(2001),
roundId: 28_500_000,
resolvedAt: 1_710_000_059_999,
decimals: 8,
price: "40067545000".parse::<I256>().unwrap(),
}
.abi_encode();
let provider = provider(
b"gravity://3/2001/price_feed?provider=binance_index_kline_v1",
vec![wrapped_jwk(1, U256::from(1_710_000_059_999u64), &payload)],
);

let tx = construct_oracle_record_transaction(provider, 0, 0).unwrap();
let call = recordBatchCall::abi_decode(tx.input()).unwrap();
assert_eq!(call.sourceType, source_types::PRICE_FEED);
assert_eq!(call.sourceId, U256::from(2001));
assert_eq!(call.nonces, vec![1]);
assert_eq!(call.blockNumbers, vec![U256::from(1_710_000_059_999u64)]);
assert_eq!(call.payloads, vec![Bytes::from(payload)]);
assert_eq!(call.callbackGasLimits, vec![U256::from(CALLBACK_GAS_LIMIT)]);
}

#[test]
fn rejects_provider_source_types_not_implemented_by_core() {
let provider = provider(
b"gravity://3/1001/price_feed",
vec![wrapped_jwk(1, U256::from(60_000), b"price")],
b"gravity://6/1001/settlement",
vec![wrapped_jwk(1, U256::from(60_000), b"settlement")],
);
let error = construct_oracle_record_transaction(provider, 0, 0).unwrap_err();
assert_eq!(error, "Unsupported oracle source type: 3");
assert_eq!(error, "Unsupported oracle source type: 6");
}

#[test]
Expand Down
51 changes: 49 additions & 2 deletions crates/pipe-exec-layer-ext-v2/relayer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

This crate converts finalized external-source observations into the
UnsupportedJWK payloads used by Gravity validator consensus. This core slice
implements source type `0` (`GravityPortal.MessageSent`). Provider-specific
source types are added in separate modules and PRs.
implements source type `0` (`GravityPortal.MessageSent`) and source type `3`
(Binance USD-M index-price klines). Other providers remain separate slices.

## Task Identity

Expand All @@ -24,9 +24,56 @@ Source type `0` example:
gravity://0/1/events?portal=0x0000000000000000000000000000000000000001&fromBlock=19000000
```

Source type `3` example:

```text
gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1710000000000&decimals=8&graceMs=120000
```

RPC URLs are local validator configuration. They are not stored in the task
URI or committed on-chain.

For Binance, the local URL is the USD-M Futures base URL. The adapter appends
`/fapi/v1/indexPriceKlines` and the deterministic query parameters. The public
index-kline endpoint does not require API credentials. `baseUrl` and unknown
query parameters are rejected in the on-chain URI.

## Binance Index Price Delivery

For one `(sourceType=3, feedId)` task, delivery nonce `n` maps to exactly one
bucket:

```text
bucketStart(n) = configuredBucketStart + (n - 1) * interval
bucketEnd(n) = bucketStart(n) + interval - 1
sourcePosition = bucketEnd(n)
roundId = bucketStart(n) / interval
resolvedAt = bucketEnd(n)
```

The adapter waits until `bucketEnd + graceMs`, requests exactly that bucket,
and accepts exactly one response row whose open and close timestamps match.
The callback payload is:

```solidity
abi.encode(
uint256 feedId,
uint64 roundId,
uint64 resolvedAt,
uint8 decimals,
int256 price
)
```

The decimal parser rejects negative, zero, malformed, overflowing, or
non-representable prices. HTTP connect/request timeouts and a 64 KiB response
limit bound validator resource use. Supported fixed intervals are `1m`, `3m`,
`5m`, `15m`, `30m`, `1h`, `2h`, `4h`, `6h`, `8h`, `12h`, `1d`, and `3d`.

Changing the interval or bucket origin for an active feed changes the
nonce-to-position history. Registration and runtime reconciliation reject that
mismatch; deploy the changed task under a new `feedId`.

## Canonical Delivery

Each source observation becomes an `OracleData` value:
Expand Down
25 changes: 22 additions & 3 deletions crates/pipe-exec-layer-ext-v2/relayer/src/data_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use alloy_primitives::{Bytes, U256};
use anyhow::Result;
use async_trait::async_trait;

use crate::blockchain_source::BlockchainEventSource;
use crate::{blockchain_source::BlockchainEventSource, price_feed_source::PriceFeedSource};

/// Data returned by oracle data sources
///
Expand All @@ -16,6 +16,7 @@ use crate::blockchain_source::BlockchainEventSource;
pub struct OracleData {
/// Strictly increasing nonce for this (sourceType, sourceId) pair
/// - For Blockchain: MessageSent.nonce
/// - For `PriceFeed`: deterministic delivery sequence
pub nonce: u128,

/// Source-defined restart position committed with this delivery
Expand All @@ -33,6 +34,7 @@ pub struct OracleData {
pub trait OracleDataSource: Send + Sync {
/// Get the source type (corresponds to NativeOracle.sourceType)
/// - 0: BLOCKCHAIN
/// - 3: `PRICE_FEED`
fn source_type(&self) -> u32;

/// Get the source ID (corresponds to NativeOracle.sourceId)
Expand All @@ -50,6 +52,9 @@ pub trait OracleDataSource: Send + Sync {
pub mod source_types {
/// Blockchain cross-chain events (e.g., GravityPortal.MessageSent)
pub const BLOCKCHAIN: u32 = 0;

/// Deterministic external price buckets
pub const PRICE_FEED: u32 = 3;
}

/// Extensible enum for runtime dispatch of data sources
Expand All @@ -62,36 +67,47 @@ pub mod source_types {
pub enum DataSourceKind {
/// Blockchain cross-chain events (sourceType=0)
Blockchain(BlockchainEventSource),

/// Deterministic external price buckets (sourceType=3)
PriceFeed(PriceFeedSource),
}

impl DataSourceKind {
pub(crate) async fn last_nonce(&self) -> Option<u128> {
match self {
Self::Blockchain(source) => source.last_nonce().await,
Self::PriceFeed(source) => source.last_nonce().await,
}
}

pub(crate) async fn last_nonce_position(&self) -> Option<u64> {
match self {
Self::Blockchain(source) => source.last_nonce_block().await,
Self::PriceFeed(source) => source.last_nonce_position().await,
}
}

pub(crate) async fn reconcile_progress(&self, nonce: u128, position: u64) {
pub(crate) async fn reconcile_progress(&self, nonce: u128, position: u64) -> Result<()> {
match self {
Self::Blockchain(source) => source.reconcile_progress(nonce, position).await,
Self::Blockchain(source) => {
source.reconcile_progress(nonce, position).await;
Ok(())
}
Self::PriceFeed(source) => source.reconcile_progress(nonce, position).await,
}
}

pub(crate) fn cursor(&self) -> u64 {
match self {
Self::Blockchain(source) => source.cursor(),
Self::PriceFeed(source) => source.cursor(),
}
}

pub(crate) const fn source_id_u64(&self) -> u64 {
match self {
Self::Blockchain(source) => source.chain_id(),
Self::PriceFeed(source) => source.feed_id(),
}
}
}
Expand All @@ -101,18 +117,21 @@ impl OracleDataSource for DataSourceKind {
fn source_type(&self) -> u32 {
match self {
Self::Blockchain(_) => source_types::BLOCKCHAIN,
Self::PriceFeed(_) => source_types::PRICE_FEED,
}
}

fn source_id(&self) -> U256 {
match self {
Self::Blockchain(s) => s.source_id(),
Self::PriceFeed(s) => s.source_id(),
}
}

async fn poll(&self) -> Result<Vec<OracleData>> {
match self {
Self::Blockchain(s) => s.poll().await,
Self::PriceFeed(s) => s.poll().await,
}
}
}
4 changes: 4 additions & 0 deletions crates/pipe-exec-layer-ext-v2/relayer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ pub mod data_source;
/// Blockchain event source (GravityPortal.MessageSent)
pub mod blockchain_source;

/// Binance index-price feed source
pub mod price_feed_source;

/// Factory for creating data sources
pub mod factory;

Expand All @@ -44,4 +47,5 @@ pub use data_source::{source_types, DataSourceKind, OracleData, OracleDataSource
pub use eth_client::EthHttpCli;
pub use factory::DataSourceFactory;
pub use oracle_manager::{JWKStruct, OracleRelayerManager, PollResult};
pub use price_feed_source::PriceFeedSource;
pub use uri_parser::{parse_oracle_uri, ParsedOracleTask};
49 changes: 48 additions & 1 deletion crates/pipe-exec-layer-ext-v2/relayer/src/oracle_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::{
blockchain_source::BlockchainEventSource,
data_source::{source_types, DataSourceKind, OracleDataSource},
persistence::{load_state_if_exists, state_file_path, RelayerState, SourceState},
price_feed_source::PriceFeedSource,
uri_parser::{parse_oracle_uri, ParsedOracleTask},
};
use anyhow::{anyhow, Result};
Expand Down Expand Up @@ -309,6 +310,15 @@ impl OracleRelayerManager {
.await?;
Ok(DataSourceKind::Blockchain(source))
}
source_types::PRICE_FEED => {
let source = PriceFeedSource::from_task_with_progress(
task,
latest_onchain_nonce,
latest_onchain_position,
Some(rpc_url),
)?;
Ok(DataSourceKind::PriceFeed(source))
}
_ => Err(anyhow!("Unknown source type: {}", task.source_type)),
}
}
Expand Down Expand Up @@ -346,7 +356,7 @@ impl OracleRelayerManager {
onchain_position,
"Reconciling local source with confirmed on-chain progress"
);
source.reconcile_progress(onchain_nonce, onchain_position).await;
source.reconcile_progress(onchain_nonce, onchain_position).await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate positions when the confirmed nonce matches locally

When a price poll has already advanced local state to nonce n and the next call reports the same on-chain nonce with a conflicting nonzero source position, this fallible reconciliation is skipped by the guard above because current_position is already nonzero. The source therefore misses the advertised runtime history check and proceeds to emit nonce n + 1 from an incompatible history; reconciliation must also run, or explicitly error, when equal nonces have different positions.

Useful? React with 👍 / 👎.

}
}

Expand Down Expand Up @@ -461,6 +471,8 @@ mod tests {

const URI: &str =
"gravity://0/1/events?portal=0x0000000000000000000000000000000000000001&fromBlock=100";
const PRICE_URI: &str =
"gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1710000000000&decimals=8";

fn state(last_nonce: u128, last_position: u64, cursor: u64) -> RelayerState {
let mut state = RelayerState::new();
Expand Down Expand Up @@ -542,4 +554,39 @@ mod tests {
let position = u128::from(u64::MAX) + 1;
assert!(u64::try_from(position).is_err());
}

#[tokio::test]
async fn adds_binance_price_feed_without_network_access() {
let datadir = tempfile::tempdir().unwrap();
let manager = OracleRelayerManager::new(datadir.path().to_path_buf());

manager.add_uri(PRICE_URI, "https://fapi.binance.com", 0, 0).await.unwrap();

assert!(manager.has_uri(PRICE_URI).await);
}

#[tokio::test]
async fn rejects_binance_history_mismatch_during_registration() {
let datadir = tempfile::tempdir().unwrap();
let manager = OracleRelayerManager::new(datadir.path().to_path_buf());

let error = manager
.add_uri(PRICE_URI, "https://fapi.binance.com", 2, 1_710_000_059_999)
.await
.unwrap_err();

assert!(error.to_string().contains("task history mismatch"));
}

#[tokio::test]
async fn rejects_binance_history_mismatch_during_runtime_reconcile() {
let datadir = tempfile::tempdir().unwrap();
let manager = OracleRelayerManager::new(datadir.path().to_path_buf());
manager.add_uri(PRICE_URI, "https://fapi.binance.com", 1, 1_710_000_059_999).await.unwrap();

let error =
manager.poll_uri(PRICE_URI, Some(2), Some(1_710_000_060_000)).await.unwrap_err();

assert!(error.to_string().contains("task history mismatch"));
}
}
Loading
Loading