From 2b46a42f637f29075ee0c54313984ad23ad0f986 Mon Sep 17 00:00:00 2001 From: ByteYue Date: Tue, 14 Jul 2026 15:56:14 +0800 Subject: [PATCH 01/11] feat(oracle): add hardened Binance and Polygon sources --- .../onchain_config/jwk_consensus_config.rs | 12 +- .../execute/src/onchain_config/jwk_oracle.rs | 279 +++- .../src/onchain_config/observed_jwk.rs | 14 +- .../src/onchain_config/oracle_state.rs | 64 +- .../src/onchain_config/oracle_task_helpers.rs | 75 +- .../relayer/ORACLE_CANONICAL_PAYLOADS.md | 203 +++ .../POLYMARKET_SETTLEMENT_LIVE_TEST.md | 82 + .../pipe-exec-layer-ext-v2/relayer/README.md | 346 +--- .../relayer/src/blockchain_source.rs | 3 +- .../relayer/src/data_source.rs | 65 +- .../relayer/src/eth_client.rs | 18 +- .../relayer/src/factory.rs | 87 - .../pipe-exec-layer-ext-v2/relayer/src/lib.rs | 10 +- .../relayer/src/oracle_manager.rs | 196 ++- .../relayer/src/persistence.rs | 23 +- .../src/polymarket_settlement_source.rs | 785 +++++++++ .../relayer/src/price_feed_source.rs | 1439 +++++++++++++++++ .../relayer/src/uri_parser.rs | 48 +- 18 files changed, 3154 insertions(+), 595 deletions(-) create mode 100644 crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md create mode 100644 crates/pipe-exec-layer-ext-v2/relayer/POLYMARKET_SETTLEMENT_LIVE_TEST.md delete mode 100644 crates/pipe-exec-layer-ext-v2/relayer/src/factory.rs create mode 100644 crates/pipe-exec-layer-ext-v2/relayer/src/polymarket_settlement_source.rs create mode 100644 crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_consensus_config.rs b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_consensus_config.rs index 842fe98b55..ec63d56f7b 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_consensus_config.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_consensus_config.rs @@ -92,18 +92,18 @@ where providers } - /// Fetch and parse blockchain providers (sourceType=0) + /// Fetch and parse relayer-backed providers (sourceType=0, sourceType=6, ...) /// Uses shared OracleTaskClient for task enumeration - fn fetch_blockchain_providers( + fn fetch_relayer_providers( &self, block_id: BlockId, ) -> Vec { - let task_uris = self.oracle_client().fetch_blockchain_task_uris(block_id); + let task_uris = self.oracle_client().fetch_relayer_task_uris(block_id); task_uris .into_iter() .map(|(uri, nonce)| { - info!(uri = %uri, nonce = nonce, "Found blockchain monitoring task"); + info!(uri = %uri, nonce = nonce, "Found relayer-backed oracle task"); gravity_api_types::on_chain_config::jwks::OIDCProvider { name: uri.clone(), config_url: uri, @@ -125,8 +125,8 @@ where // 1. Fetch JWK providers (sourceType=1) all_providers.extend(self.fetch_jwk_providers(block_id)); - // 2. Fetch blockchain providers (sourceType=0) - all_providers.extend(self.fetch_blockchain_providers(block_id)); + // 2. Fetch relayer-backed providers (sourceType=0, sourceType=6, ...) + all_providers.extend(self.fetch_relayer_providers(block_id)); info!(provider_count = all_providers.len(), "Fetched oracle task providers"); diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_oracle.rs b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_oracle.rs index bcff797cb5..8a78006f40 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_oracle.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_oracle.rs @@ -1,8 +1,8 @@ //! JWK Oracle module for writing oracle updates via NativeOracle.record() //! //! This module handles the WRITE path for ALL oracle updates in the new Oracle architecture: -//! - RSA JWKs: NativeOracle.record(sourceType=1, sourceId=keccak256(issuer)) -//! - UnsupportedJWK (blockchain events): NativeOracle.recordBatch() for multiple logs +//! - RSA JWKs: rejected here because the active execution path uses UnsupportedJWK payloads +//! - UnsupportedJWK: NativeOracle.recordBatch() for oracle payloads //! //! For blockchain events, the payload from relayer is ABI-encoded and passed through unchanged. //! This ensures byte-exact match between relayer, on-chain storage, and read-back for comparison. @@ -10,13 +10,14 @@ use super::{new_system_call_txn, NATIVE_ORACLE_ADDR}; use alloy_primitives::{Bytes, U256}; use alloy_sol_macro::sol; -use alloy_sol_types::SolCall; +use alloy_sol_types::{SolCall, SolValue}; use gravity_api_types::on_chain_config::jwks::{JWKStruct, ProviderJWKs}; use reth_ethereum_primitives::TransactionSigned; +use reth_pipe_exec_layer_relayer::parse_oracle_uri; use tracing::{debug, info, warn}; /// Default callback gas limit for oracle updates -const CALLBACK_GAS_LIMIT: u64 = 500_000; +const CALLBACK_GAS_LIMIT: u64 = 2_000_000; // ============================================================================= // Solidity Types (NativeOracle function signatures) @@ -58,70 +59,38 @@ fn is_unsupported_jwk(jwk: &JWKStruct) -> bool { jwk.type_name == "0x1::jwks::Unsupported_JWK" } -/// Parse chain_id from issuer URI -/// Format: gravity://{source_type}/{chain_id}/{task_type}?... -fn parse_chain_id_from_issuer(issuer: &[u8]) -> Option { - let issuer_str = String::from_utf8_lossy(issuer); - if issuer_str.starts_with("gravity://") { - let after_protocol = &issuer_str[10..]; - // Skip source_type (first segment), get chain_id (second segment) - // Format: {source_type}/{chain_id}/{task_type}?... - let mut parts = after_protocol.split('/'); - let _source_type = parts.next()?; // Skip source_type - let chain_id_str = parts.next()?; - return chain_id_str.parse().ok(); - } - None +/// Parse source type and source id from issuer URI. +/// Format: gravity://{source_type}/{source_id}/{task_type}?... +fn parse_source_from_issuer(issuer: &[u8]) -> Option<(u32, u64)> { + let issuer_str = std::str::from_utf8(issuer).ok()?; + let task = parse_oracle_uri(issuer_str).ok()?; + Some((task.source_type, task.source_id)) } -/// Extract nonce, block_number, and inner payload from ABI-encoded event data -/// Payload format: alloy's abi_encode(&(u128, U256, &[u8])) -/// -/// alloy encodes (u128, U256, &[u8]) as a dynamic tuple with structure: -/// - bytes 0-31: offset to tuple data (always 32 = 0x20) -/// - bytes 32-63: nonce (uint128, right-aligned, so nonce is at bytes 48-63) -/// - bytes 64-95: block_number (uint256) -/// - bytes 96-127: offset to bytes data (relative to tuple start at byte 32) -/// - bytes 128-159: payload length -/// - bytes 160+: payload data -/// -/// Returns (nonce, block_number, inner_payload) +/// Extract a canonical ABI `(uint128 nonce, uint256 blockNumber, bytes payload)` tuple. fn extract_nonce_block_and_payload(data: &[u8]) -> Option<(u128, U256, Vec)> { - if data.len() < 160 { - warn!( - target: "gravity::onchain_config::jwk_oracle", - data_len = data.len(), - "Data too short for ABI decoding" - ); - return None; - } - - // nonce is at bytes 32-63, right-aligned u128 so actual value is at bytes 48-63 - let nonce_bytes = &data[48..64]; - let nonce = u128::from_be_bytes(nonce_bytes.try_into().ok()?); - - // block_number is at bytes 64-95 (full U256) - let block_number = U256::from_be_slice(&data[64..96]); - - // Payload length is at bytes 128-159 (right-aligned u256) - let length_bytes = &data[128..160]; - let payload_len = u64::from_be_bytes(length_bytes[24..32].try_into().ok()?) as usize; - - // Check we have enough data for the payload - if data.len() < 160 + payload_len { + let decoded = match <(u128, U256, Bytes)>::abi_decode(data) { + Ok(decoded) => decoded, + Err(error) => { + warn!( + target: "gravity::onchain_config::jwk_oracle", + data_len = data.len(), + ?error, + "Failed to decode oracle payload wrapper" + ); + return None; + } + }; + if decoded.abi_encode() != data { warn!( target: "gravity::onchain_config::jwk_oracle", data_len = data.len(), - payload_len = payload_len, - "Not enough data for payload" + "Rejected non-canonical oracle payload wrapper" ); return None; } - // Extract the inner payload starting at byte 160 - let inner_payload = data[160..160 + payload_len].to_vec(); - - Some((nonce, block_number, inner_payload)) + Some((decoded.0, decoded.1, decoded.2.to_vec())) } // ============================================================================= @@ -131,7 +100,7 @@ fn extract_nonce_block_and_payload(data: &[u8]) -> Option<(u128, U256, Vec)> /// Construct transaction for oracle update via NativeOracle.record() /// /// This is the unified entry point for ALL oracle updates. It routes based on JWK type: -/// - RSA_JWK → sourceType=1 (JWK), payload=ABI(issuer, version, jwks[]) +/// - RSA_JWK → explicit error; this execution path is not enabled /// - UnsupportedJWK → Uses recordBatch for ALL logs (payload passed through unchanged) /// /// Note: All JWKs in provider_jwks.jwks are guaranteed to be of the same type @@ -165,25 +134,25 @@ pub fn construct_oracle_record_transaction( "RSA JWK path entered unexpectedly — rejecting (unsupported in production)" ); Err(format!( - "RSA JWK oracle record path is not supported: issuer={}, jwk_count={}", + "RSA JWK oracle record path is not enabled: issuer={}, jwk_count={}", issuer_str, provider_jwks.jwks.len() )) } else if is_unsupported_jwk(first_jwk) { - // Blockchain/oracle events - use recordBatch for ALL logs - construct_blockchain_batch_transaction(provider_jwks, nonce, gas_price) + // Generic oracle records - use recordBatch for ALL entries + construct_unsupported_oracle_batch_transaction(provider_jwks, nonce, gas_price) } else { warn!(target: "gravity::onchain_config::jwk_oracle", "Unknown JWK type '{}' for issuer: {}", first_jwk.type_name, issuer_str); Err(format!("Unknown JWK type '{}' for issuer: {}", first_jwk.type_name, issuer_str)) } } -/// Construct transaction for blockchain events using recordBatch() +/// Construct transaction for unsupported JWK oracle data using recordBatch() /// /// This handles ALL UnsupportedJWK entries (each represents one event). /// The payload is passed through UNCHANGED from relayer - this ensures /// byte-exact match between what relayer sends and what gets stored on-chain. -fn construct_blockchain_batch_transaction( +fn construct_unsupported_oracle_batch_transaction( provider_jwks: ProviderJWKs, nonce: u64, gas_price: u128, @@ -191,19 +160,22 @@ fn construct_blockchain_batch_transaction( let issuer = &provider_jwks.issuer; let jwks = &provider_jwks.jwks; - // Parse chain_id from issuer - let chain_id = parse_chain_id_from_issuer(issuer) - .ok_or_else(|| format!("Failed to parse chain_id from issuer: {:?}", issuer))?; - info!(target: "gravity::onchain_config::jwk_oracle", "jwk chain_id: {}, len {:?}", chain_id, jwks.len()); + // Parse NativeOracle coordinates from issuer + let (source_type, source_id) = parse_source_from_issuer(issuer) + .ok_or_else(|| format!("Failed to parse source coordinates from issuer: {:?}", issuer))?; + info!( + target: "gravity::onchain_config::jwk_oracle", + source_type, + source_id, + len = jwks.len(), + "unsupported JWK oracle batch" + ); // All JWKs are guaranteed to be unsupported type when entering this function if jwks.is_empty() { return Err("No blockchain event JWKs found".to_string()); } - // Parse sourceType from first JWK's type_name (all have same type) - let source_type: u32 = 0; - // Build batch arrays let mut nonces: Vec = Vec::with_capacity(jwks.len()); let mut block_numbers: Vec = Vec::with_capacity(jwks.len()); @@ -231,7 +203,7 @@ fn construct_blockchain_batch_transaction( nonces.push(event_nonce); block_numbers.push(block_number); - // Use the inner payload (the original MessageSent.payload) + // Use the inner payload (the original resolver payload) // This is what the user put in and what gets passed to the callback payloads.push(inner_payload.into()); gas_limits.push(U256::from(CALLBACK_GAS_LIMIT)); @@ -247,16 +219,16 @@ fn construct_blockchain_batch_transaction( info!( issuer = %String::from_utf8_lossy(issuer), - chain_id = chain_id, source_type = source_type, - event_count = nonces.len(), - "Constructing blockchain recordBatch transaction (pass-through payload)" + source_id = source_id, + item_count = nonces.len(), + "Constructing oracle recordBatch transaction (pass-through payload)" ); // Use recordBatch for multiple events let call = recordBatchCall { sourceType: source_type, - sourceId: U256::from(chain_id), + sourceId: U256::from(source_id), nonces, blockNumbers: block_numbers, payloads, @@ -268,3 +240,158 @@ fn construct_blockchain_batch_transaction( } // convert_oracle_rsa_to_api_jwk is now provided by super::types + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::Transaction; + use alloy_sol_macro::sol; + use alloy_sol_types::SolValue; + use reth_pipe_exec_layer_relayer::{OracleDataSource, PriceFeedSource}; + + sol! { + struct PriceObservationForTest { + bytes32 dataSourceId; + uint64 observedAt; + int256 price; + uint256 weight; + } + + struct PricePayloadForTest { + uint256 feedId; + uint64 roundId; + uint64 resolvedAt; + uint8 decimals; + uint8 aggregationMode; + uint256 minSourceCount; + uint256 minTotalWeight; + uint64 maxStaleness; + PriceObservationForTest[] observations; + } + } + + #[test] + fn test_parse_source_from_issuer() { + let issuer = b"gravity://3/1001/price_feed?provider=binance_index_kline_v1"; + assert_eq!(parse_source_from_issuer(issuer), Some((3, 1001))); + } + + #[test] + fn test_extract_nonce_block_and_payload() { + let payload = b"oracle-payload"; + let encoded = SolValue::abi_encode(&(7u128, U256::from(3020u64), payload.as_slice())); + + let (nonce, block_number, inner_payload) = + extract_nonce_block_and_payload(&encoded).expect("extract wrapper"); + + assert_eq!(nonce, 7); + assert_eq!(block_number, U256::from(3020u64)); + assert_eq!(inner_payload, payload); + } + + #[test] + fn test_extract_rejects_noncanonical_wrapper() { + let mut encoded = + SolValue::abi_encode(&(7u128, U256::from(3020u64), b"oracle-payload".as_slice())); + encoded.push(0); + + assert!(extract_nonce_block_and_payload(&encoded).is_none()); + } + + #[test] + fn test_rsa_jwk_path_returns_error_without_panicking() { + let provider = ProviderJWKs { + issuer: b"https://issuer.example".to_vec(), + version: 1, + jwks: vec![JWKStruct { type_name: "0x1::jwks::RSA_JWK".to_string(), data: vec![] }], + }; + + let err = construct_oracle_record_transaction(provider, 0, 0).unwrap_err(); + assert!(err.contains("not enabled")); + } + + #[test] + fn test_construct_unsupported_batch_uses_source_from_issuer() { + let resolver_payload = b"price-feed-resolver-payload"; + let wrapped_payload = + SolValue::abi_encode(&(1u128, U256::from(3020u64), resolver_payload.as_slice())); + let provider = ProviderJWKs { + issuer: b"gravity://3/1001/price_feed?provider=inline_fixture_v1&round=1".to_vec(), + version: 1, + jwks: vec![JWKStruct { + type_name: "0x1::jwks::Unsupported_JWK".to_string(), + data: wrapped_payload, + }], + }; + + let tx = construct_oracle_record_transaction(provider, 0, 0).expect("construct tx"); + let call = recordBatchCall::abi_decode(tx.input()).expect("decode recordBatch call"); + + assert_eq!(call.sourceType, 3); + assert_eq!(call.sourceId, U256::from(1001u64)); + assert_eq!(call.nonces, vec![1]); + assert_eq!(call.blockNumbers, vec![U256::from(3020u64)]); + assert_eq!(call.payloads, vec![Bytes::copy_from_slice(resolver_payload)]); + assert_eq!(call.callbackGasLimits, vec![U256::from(CALLBACK_GAS_LIMIT)]); + } + + #[test] + fn test_construct_unsupported_batch_supports_polymarket_source_type() { + let resolver_payload = b"polymarket-settlement-resolver-payload"; + let wrapped_payload = + SolValue::abi_encode(&(1u128, U256::from(89_222_209u64), resolver_payload.as_slice())); + let provider = ProviderJWKs { + issuer: b"gravity://6/1897398/polymarket_settlement?fromBlock=89222200".to_vec(), + version: 1, + jwks: vec![JWKStruct { + type_name: "0x1::jwks::Unsupported_JWK".to_string(), + data: wrapped_payload, + }], + }; + + let tx = construct_oracle_record_transaction(provider, 0, 0).expect("construct tx"); + let call = recordBatchCall::abi_decode(tx.input()).expect("decode recordBatch call"); + + assert_eq!(call.sourceType, 6); + assert_eq!(call.sourceId, U256::from(1_897_398u64)); + assert_eq!(call.nonces, vec![1]); + assert_eq!(call.blockNumbers, vec![U256::from(89_222_209u64)]); + assert_eq!(call.payloads, vec![Bytes::copy_from_slice(resolver_payload)]); + } + + #[tokio::test] + async fn test_price_feed_source_payload_reaches_record_batch() { + let uri = "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-a:2000:10000000000:1,source-b:2000:10200000000:2,source-c:2000:9800000000:1"; + let task = parse_oracle_uri(uri).expect("parse price feed uri"); + let source = PriceFeedSource::from_task(&task, 0).expect("create price feed source"); + let data = source.poll().await.expect("poll price feed source"); + assert_eq!(data.len(), 1); + + let provider = ProviderJWKs { + issuer: uri.as_bytes().to_vec(), + version: 1, + jwks: vec![JWKStruct { + type_name: "0x1::jwks::Unsupported_JWK".to_string(), + data: data[0].payload.to_vec(), + }], + }; + + let tx = construct_oracle_record_transaction(provider, 0, 0).expect("construct tx"); + let call = recordBatchCall::abi_decode(tx.input()).expect("decode recordBatch call"); + + assert_eq!(call.sourceType, 3); + assert_eq!(call.sourceId, U256::from(1)); + assert_eq!(call.nonces, vec![1]); + assert_eq!(call.blockNumbers, vec![U256::from(2010u64)]); + assert_eq!(call.payloads.len(), 1); + + let payload = + PricePayloadForTest::abi_decode(&call.payloads[0]).expect("decode resolver payload"); + assert_eq!(payload.feedId, U256::from(1)); + assert_eq!(payload.roundId, 1); + assert_eq!(payload.resolvedAt, 2010); + assert_eq!(payload.decimals, 8); + assert_eq!(payload.aggregationMode, 1); + assert_eq!(payload.observations.len(), 3); + } +} diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/observed_jwk.rs b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/observed_jwk.rs index f6e3243264..2b73412a58 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/observed_jwk.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/observed_jwk.rs @@ -82,9 +82,9 @@ where Some(oracle_jwks.entries.into_iter().map(convert_oracle_provider_jwks).collect()) } - /// Fetch blockchain event providers using shared OracleTaskClient - fn fetch_blockchain_providers(&self, block_id: BlockId) -> Vec { - let task_uris = self.oracle_client().fetch_blockchain_task_uris(block_id); + /// Fetch relayer-backed event providers using shared OracleTaskClient. + fn fetch_relayer_providers(&self, block_id: BlockId) -> Vec { + let task_uris = self.oracle_client().fetch_relayer_task_uris(block_id); task_uris .into_iter() @@ -114,10 +114,10 @@ where all_entries.extend(jwk_entries); } - // 2. Fetch blockchain events from NativeOracle (for configured chains from - // OracleTaskConfig) - let blockchain_entries = self.fetch_blockchain_providers(block_id); - all_entries.extend(blockchain_entries); + // 2. Fetch relayer-backed entries from NativeOracle (for configured gravity:// tasks from + // OracleTaskConfig). + let relayer_entries = self.fetch_relayer_providers(block_id); + all_entries.extend(relayer_entries); info!( jwk_count = all_entries.iter().filter(|e| e.issuer.starts_with(b"https://")).count(), diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_state.rs b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_state.rs index e222eaa3a4..e03941ab52 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_state.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_state.rs @@ -6,7 +6,7 @@ use super::{ base::OnchainConfigFetcher, - oracle_task_helpers::{OracleTaskClient, SOURCE_TYPE_BLOCKCHAIN}, + oracle_task_helpers::{OracleTaskClient, RELAYER_BACKED_SOURCE_TYPES}, NATIVE_ORACLE_ADDR, SYSTEM_CALLER, }; use alloy_eips::BlockId; @@ -106,48 +106,48 @@ where }) } - /// Fetch all oracle source states for registered blockchain tasks + /// Fetch all oracle source states for registered relayer-backed tasks. /// /// Returns BCS-serialized OracleSourceStates for registered sources. pub fn fetch(&self, block_id: BlockId) -> Option { let task_client = OracleTaskClient::new(self.base_fetcher); let mut results = Vec::new(); - // Get all registered blockchain source IDs - let source_ids = task_client - .fetch_registered_source_ids(SOURCE_TYPE_BLOCKCHAIN, block_id) - .unwrap_or_default(); - - info!( - target: "oracle_state", - source_count = source_ids.len(), - "Fetching oracle source states" - ); - - for source_id in source_ids { - // Get latest nonce for this source - let latest_nonce = task_client - .call_get_latest_nonce(SOURCE_TYPE_BLOCKCHAIN, source_id, block_id) - .unwrap_or(0); - - // Fetch the latest record if nonce > 0 - let latest_record = - self.fetch_latest_record(SOURCE_TYPE_BLOCKCHAIN, source_id, latest_nonce, block_id); + for source_type in RELAYER_BACKED_SOURCE_TYPES { + let source_ids = + task_client.fetch_registered_source_ids(*source_type, block_id).unwrap_or_default(); info!( target: "oracle_state", - source_id = source_id.to_string(), - latest_nonce, - has_record = latest_record.is_some(), - "Fetched oracle source state" + source_type, + source_count = source_ids.len(), + "Fetching oracle source states" ); - results.push(OracleSourceState { - source_type: SOURCE_TYPE_BLOCKCHAIN, - source_id: source_id.try_into().unwrap_or(0), - latest_nonce, - latest_record, - }); + for source_id in source_ids { + let latest_nonce = task_client + .call_get_latest_nonce(*source_type, source_id, block_id) + .unwrap_or(0); + + let latest_record = + self.fetch_latest_record(*source_type, source_id, latest_nonce, block_id); + + info!( + target: "oracle_state", + source_type, + source_id = source_id.to_string(), + latest_nonce, + has_record = latest_record.is_some(), + "Fetched oracle source state" + ); + + results.push(OracleSourceState { + source_type: *source_type, + source_id: source_id.try_into().unwrap_or(0), + latest_nonce, + latest_record, + }); + } } Some(bcs::to_bytes(&results).expect("Failed to BCS serialize OracleSourceStates").into()) diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_task_helpers.rs b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_task_helpers.rs index 3076445e58..613a6160da 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_task_helpers.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_task_helpers.rs @@ -23,6 +23,16 @@ use tracing::{info, warn}; /// Source type for blockchain events in NativeOracle pub const SOURCE_TYPE_BLOCKCHAIN: u32 = 0; +/// Source type for price feed rounds. +pub const SOURCE_TYPE_PRICE_FEED: u32 = 3; + +/// Source type for Polygon Polymarket settlement mirrors. +pub const SOURCE_TYPE_POLYMARKET_SETTLEMENT: u32 = 6; + +/// Source types whose tasks are backed by the relayer / UnsupportedJWK path. +pub const RELAYER_BACKED_SOURCE_TYPES: &[u32] = + &[SOURCE_TYPE_BLOCKCHAIN, SOURCE_TYPE_PRICE_FEED, SOURCE_TYPE_POLYMARKET_SETTLEMENT]; + // Re-export SOURCE_TYPE_JWK from types for consistency pub use super::types::SOURCE_TYPE_JWK; @@ -66,6 +76,18 @@ sol! { ) external view returns (uint128 nonce); } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relayer_backed_source_types_include_price_feeds() { + assert!(RELAYER_BACKED_SOURCE_TYPES.contains(&SOURCE_TYPE_BLOCKCHAIN)); + assert!(RELAYER_BACKED_SOURCE_TYPES.contains(&SOURCE_TYPE_PRICE_FEED)); + assert!(RELAYER_BACKED_SOURCE_TYPES.contains(&SOURCE_TYPE_POLYMARKET_SETTLEMENT)); + } +} + // ============================================================================= // OracleTaskClient - Shared Helper for Contract Calls // ============================================================================= @@ -161,20 +183,33 @@ where /// Returns a vector of (URI, nonce) tuples for all configured blockchain tasks. /// Returns tasks even when nonce is 0 (no data recorded yet) to enable discovery. pub fn fetch_blockchain_task_uris(&self, block_id: BlockId) -> Vec<(String, u128)> { + self.fetch_task_uris(SOURCE_TYPE_BLOCKCHAIN, block_id) + } + + /// Fetch all relayer-backed task URIs with their nonces. + pub fn fetch_relayer_task_uris(&self, block_id: BlockId) -> Vec<(String, u128)> { + RELAYER_BACKED_SOURCE_TYPES + .iter() + .flat_map(|source_type| self.fetch_task_uris(*source_type, block_id)) + .collect() + } + + /// Fetch task URIs for a source type with their latest NativeOracle nonce. + pub fn fetch_task_uris(&self, source_type: u32, block_id: BlockId) -> Vec<(String, u128)> { let mut results = Vec::new(); - // Get all registered blockchain source IDs let source_ids = - self.fetch_registered_source_ids(SOURCE_TYPE_BLOCKCHAIN, block_id).unwrap_or_default(); + self.fetch_registered_source_ids(source_type, block_id).unwrap_or_default(); info!( target: "oracle_task_helper", + source_type, length = source_ids.len(), "oracle task source ids length" ); for source_id in source_ids { - self.process_source_tasks(source_id, block_id, &mut results); + self.process_source_tasks(source_type, source_id, block_id, &mut results); } results @@ -183,24 +218,23 @@ where /// Process all tasks for a single source ID fn process_source_tasks( &self, + source_type: u32, source_id: U256, block_id: BlockId, results: &mut Vec<(String, u128)>, ) { // Fetch the latest nonce for this source (0 if no data recorded yet) - let nonce = - self.call_get_latest_nonce(SOURCE_TYPE_BLOCKCHAIN, source_id, block_id).unwrap_or(0); + let nonce = self.call_get_latest_nonce(source_type, source_id, block_id).unwrap_or(0); info!( target: "oracle_task_helper", + source_type, source_id = source_id.to_string(), nonce, "oracle task source id and nonce" ); - let Some(task_names) = - self.call_get_task_names(SOURCE_TYPE_BLOCKCHAIN, source_id, block_id) - else { + let Some(task_names) = self.call_get_task_names(source_type, source_id, block_id) else { return; }; @@ -211,13 +245,14 @@ where ); for task_name in task_names { - self.process_single_task(source_id, task_name, nonce, block_id, results); + self.process_single_task(source_type, source_id, task_name, nonce, block_id, results); } } /// Process a single task and add valid URI to results fn process_single_task( &self, + source_type: u32, source_id: U256, task_name: B256, nonce: u128, @@ -230,8 +265,7 @@ where "oracle task task name" ); - let Some(task) = self.call_get_task(SOURCE_TYPE_BLOCKCHAIN, source_id, task_name, block_id) - else { + let Some(task) = self.call_get_task(source_type, source_id, task_name, block_id) else { return; }; @@ -252,11 +286,26 @@ where "oracle task uri string" ); - // Validate URI + // Validate URI and ensure the URI coordinates match the on-chain task + // coordinates that discovered it. match reth_pipe_exec_layer_relayer::uri_parser::parse_oracle_uri(&uri_string) { - Ok(_) => { + Ok(parsed) + if parsed.source_type == source_type && + U256::from(parsed.source_id) == source_id => + { results.push((uri_string, nonce)); } + Ok(parsed) => { + warn!( + target: "oracle_task_helper", + uri_string = uri_string, + expected_source_type = source_type, + expected_source_id = source_id.to_string(), + parsed_source_type = parsed.source_type, + parsed_source_id = parsed.source_id, + "Oracle task URI coordinates do not match registered source" + ); + } Err(e) => { warn!( target: "oracle_task_helper", diff --git a/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md b/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md new file mode 100644 index 0000000000..e1685ad21b --- /dev/null +++ b/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md @@ -0,0 +1,203 @@ +# Oracle Relayer Protocol + +The relayer converts deterministic external observations into bytes consumed by +the existing UnsupportedJWK consensus path. The production-candidate oracle +adapters are: + +| sourceType | Adapter | Upstream | +| --- | --- | --- | +| `3` | `binance_index_kline_v1` | Binance USD-M closed index-price kline | +| `6` | `polymarket_settlement` | Finalized Polygon CTF resolution log | + +The existing `sourceType=0` bridge event adapter remains independent from these +oracle products. + +## Configuration split + +The on-chain `gravity://` URI is the consensus task identity. It contains only +public deterministic parameters. The relayer JSON config maps the exact URI to +a validator-local upstream URL. + +```json +{ + "uri_mappings": { + "gravity://3/1001/price_feed?...": "https://provider.example", + "gravity://6/7202626/polymarket_settlement?...": "https://polygon.example" + } +} +``` + +Do not put API keys, tenant paths, URL userinfo, or provider URLs in the on-chain +URI. The Binance adapter rejects `baseUrl` as a URI parameter. + +## Consensus wrapper + +Every adapter returns one or more `OracleData` values: + +```rust +OracleData { + nonce: u128, + payload: abi.encode(nonce, source_block_or_time, resolver_payload), +} +``` + +The JWK execution path unwraps this tuple and calls: + +```solidity +NativeOracle.recordBatch( + sourceType, + sourceId, + nonces, + blockNumbers, + resolverPayloads, + callbackGasLimits +) +``` + +The execution layer assigns a `2,000,000` gas callback budget per record. This +budget is covered by the resolver's maximum-size observation test; failed +callbacks do not discard the raw `NativeOracle` record and can be replayed. + +Delivery nonce is sequential for each `(sourceType, sourceId)`. It is not a +Binance round id and it is not a Polygon log index. + +## Binance price feed + +URI shape: + +```text +gravity://3//price_feed + ?provider=binance_index_kline_v1 + &pair= + &interval=1m + &bucketStartMs= + &continuous=true + &decimals=8 + &aggregationMode=2 + &minSourceCount=1 + &minTotalWeight=1 + &maxStaleness= + &graceMs= +``` + +For delivery nonce `n` in continuous mode: + +```text +bucketStart(n) = configuredBucketStart + (n - 1) * intervalMs +bucketEnd(n) = bucketStart(n) + intervalMs - 1 +roundId(n) = bucketStart(n) / intervalMs +resolvedAt(n) = bucketEnd(n) +``` + +`bucketStartMs` is the bucket origin for nonce `1` and is immutable for the +lifetime of a `feedId`. Startup reconciliation rejects a confirmed cursor that +does not match this mapping. Use a new `feedId` when introducing a new origin. +For fixed, non-continuous tasks, a confirmed cursor equal to the target bucket +means the URI was already delivered and must not be fetched again. A newer +bucket uses the next delivery nonce; a bucket older than confirmed history is +rejected. + +`round`, `resolvedAt`, and `blockNumber` are derived from the exact bucket and +cannot be overridden in a Binance task URI. + +The request is: + +```text +GET /fapi/v1/indexPriceKlines + ?pair= + &interval= + &startTime= + &endTime= + &limit=1 +``` + +Canonical acceptance rules: + +- pair is 1-32 uppercase ASCII letters or digits +- interval is one of the fixed-duration Binance intervals supported in code +- bucket start is interval-aligned +- local time is at least `bucketEnd + graceMs` +- response has exactly one row +- row `openTime` and `closeTime` exactly match the requested bucket +- close price is a positive decimal string +- response body is streamed into a buffer capped at 64 KiB +- connection timeout is 5 seconds and total request timeout is 15 seconds +- decimals are at most 18 and observations are capped at 16 + +The resolver payload is ABI encoding of `PriceFeedResolver.PricePayload`. The +default Binance observation id is: + +```text +keccak256("binance:usdm:indexPriceKlines:::close") +``` + +`provider=inline_fixture_v1` is an explicit deterministic test adapter. Missing +`provider` is rejected, so fixture payloads cannot be selected accidentally by +an incomplete production task URI. + +## Polymarket settlement mirror + +URI shape: + +```text +gravity://6//polymarket_settlement + ?ctf= + &condition= + &fromBlock= + &maxBlocksPerPoll= +``` + +`condition` is required. One source id represents one reviewed CTF condition; +the adapter does not scan all Polymarket settlements and ask the callback to +filter them later. + +On first poll the RPC endpoint must report chain id `137`. Each poll then: + +1. reads Polygon's finalized block number; +2. scans after the exclusive `fromBlock` cursor, at most 10,000 finalized + blocks per poll; +3. filters the configured CTF address, `ConditionResolution` signature, and + exact condition topic; +4. validates block number, log index, transaction hash, slot count (maximum + 32), and a + non-zero payout vector; +5. sorts by `(blockNumber, logIndex, txHash)` and deduplicates identical logs; +6. rejects multiple distinct settlements for one condition and assigns one + sequential Gravity delivery nonce. + +Filtered logs with malformed ABI or missing source identity fail the poll and +do not advance the cursor. Once one settlement has been returned, the one-shot +source stops scanning; cached resend and restart reconciliation handle pending +consensus delivery. + +The resolver payload contains mirror id, Polygon chain id, CTF and oracle +addresses, condition and question ids, payout vector, transaction hash, log +index, and settlement kind. + +## Progress and retries + +The source advances its in-memory cursor when it returns data. The SDK wrapper +caches that complete `PollResult`; while the returned nonce is ahead of +`NativeOracle.latestNonce`, it resends the cached bytes instead of polling the +upstream again. + +Persisted relayer progress tracks fetched data, not confirmed data. On restart: + +- on-chain ahead: fast-forward local state; +- persisted and on-chain equal: restore cursor; +- persisted ahead: roll back to the confirmed on-chain nonce and block; +- no state: start from configured cursor. + +This contract between `gravity-sdk` and `gravity-reth` is required for liveness. +Do not change source cursor semantics without updating the cached-resend and +restart-reconciliation tests together. + +## Test commands + +```bash +cargo test -p reth-pipe-exec-layer-relayer +cargo test -p reth-pipe-exec-layer-ext-v2 --lib jwk_oracle +``` + +Tests requiring public Binance or Polygon traffic are ignored by default. The +normal suite is deterministic and local. diff --git a/crates/pipe-exec-layer-ext-v2/relayer/POLYMARKET_SETTLEMENT_LIVE_TEST.md b/crates/pipe-exec-layer-ext-v2/relayer/POLYMARKET_SETTLEMENT_LIVE_TEST.md new file mode 100644 index 0000000000..db4465c771 --- /dev/null +++ b/crates/pipe-exec-layer-ext-v2/relayer/POLYMARKET_SETTLEMENT_LIVE_TEST.md @@ -0,0 +1,82 @@ +# Polymarket Settlement Live-Test Runbook + +The default relayer test suite is local and deterministic. This runbook is for +an explicitly approved Polygon RPC check of one known CTF condition. + +## Inputs + +Provide: + +- Polygon RPC URL +- CTF contract address +- exact condition id +- a recent block before its `ConditionResolution` event +- a small `maxBlocksPerPoll` range appropriate for the RPC provider + +Do not put the RPC URL into the `gravity://` URI or commit it to the repository. + +## URI + +```text +gravity://6//polymarket_settlement + ?ctf= + &condition= + &fromBlock= + &maxBlocksPerPoll= +``` + +The condition is mandatory. The adapter verifies `eth_chainId == 137` before +reading finalized logs. Scanning begins at `fromBlock + 1`. + +## Focused adapter test + +After outbound access is approved: + +```bash +POLYGON_RPC_URL='' \ +POLYMARKET_CTF_ADDRESS='' \ +POLYMARKET_CONDITION_ID='' \ +POLYMARKET_FROM_BLOCK='' \ +POLYMARKET_MAX_BLOCKS_PER_POLL='100' \ +cargo test -p reth-pipe-exec-layer-relayer \ + test_live_poll_polygon_polymarket_settlements --lib -- --ignored --nocapture +``` + +Expected evidence: + +- RPC chain id is accepted as `137` +- finalized block lookup succeeds +- one matching resolution produces one canonical payload +- source block, transaction hash, and log index match Polygon +- payout vector length equals `outcomeSlotCount` and is not all zero + +## Full local chain test + +Use the deterministic SDK suite for the consensus and execution path: + +```bash +./gravity_e2e/run_test.sh polymarket_mock --force-init +``` + +That suite proves: + +```text +finalized ConditionResolution fixture +-> gravity-reth canonical payload +-> UnsupportedJWK validator consensus +-> NativeOracle sourceType=6 record +-> PolymarketSettlementResolver +-> Polymarket market settlement and claim +``` + +## Failure handling + +- Wrong chain id: reject the endpoint; do not override the check. +- Missing finalized tag: use a Polygon provider that implements finalized block + queries. +- Empty result: verify condition topic, CTF address, start block, and finalized + height. +- Callback failure: inspect the stored raw record and callback event, fix the + configuration, then call `replaySettlement(mirrorId, nonce)`. +- Persisted progress ahead of chain state: restart reconciliation rolls back to + `NativeOracle`'s confirmed nonce and source block. diff --git a/crates/pipe-exec-layer-ext-v2/relayer/README.md b/crates/pipe-exec-layer-ext-v2/relayer/README.md index c3580a5ce6..3ba5c53121 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/README.md +++ b/crates/pipe-exec-layer-ext-v2/relayer/README.md @@ -1,335 +1,47 @@ -# Gravity Protocol Relayer +# Gravity Relayer -A URI parser and blockchain event relayer for the Gravity protocol. +This crate hosts validator-local source adapters that produce canonical bytes +for Gravity's UnsupportedJWK consensus path. -## Features +Current runtime sources: -### URI Parser (UriParser) -Supports parsing Gravity URIs in the following formats: -- `gravity://mainnet/block?strategy=head` - Monitor latest block -- `gravity://mainnet/event?address=0x...&topic0=0x...` - Monitor contract events -- `gravity://mainnet/storage?account=0x...&slot=0x...` - Monitor storage slot changes -- `gravity://mainnet/account/0x.../activity?type=erc20_transfer` - Monitor account activity +- `sourceType=0`: existing GravityPortal blockchain events +- `sourceType=3`: Binance closed index-price klines +- `sourceType=6`: finalized Polygon Polymarket CTF settlements -### Relayer (GravityRelayer) -- Periodically polls Ethereum nodes -- Maintains processing cursor state -- Detects data changes and generates update events -- Supports finalized block filtering -- Configurable polling intervals and block ranges +See [ORACLE_CANONICAL_PAYLOADS.md](./ORACLE_CANONICAL_PAYLOADS.md) for URI, +payload, nonce, and recovery invariants. -### Relayer Manager (RelayerManager) -- Manages multiple relayers for different URIs -- Centralized lifecycle management -- Supports multiple RPC endpoints -- Provides unified interface for adding and polling URIs +## Binance continuous feed -## Basic Usage - -```rust -use reth_pipe_exec_layer_relayer::{ - RelayerManager, UriParser -}; -use reth_tracing::{LayerInfo, LogFormat, RethTracer, Tracer}; -use tracing::level_filters::LevelFilter; -use tracing::info; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Initialize tracing - let tracer = RethTracer::new().with_stdout(LayerInfo::new( - LogFormat::Terminal, - LevelFilter::INFO.to_string(), - "trace".to_string(), - None, - )); - tracer.init().unwrap(); - - // 1. Create URI parser - let parser = UriParser::new(); - - // 2. Parse task URI - let task = parser.parse("gravity://mainnet/event?address=0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48&topic0=0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")?; - - // 3. Create RelayerManager - let manager = RelayerManager::new(); - - // 4. Add URI to manager (no initial state required) - manager.add_uri(&task.original_uri, "https://rpc.ankr.com/eth").await?; - - // 5. Poll for updates - let current_state = manager.poll_uri(&task.original_uri).await?; - info!("Current state: {:?}", current_state); - - Ok(()) -} -``` - -## Data Structures - -### ParsedTask -```rust -pub struct ParsedTask { - /// The parsed gravity task to be executed - pub task: GravityTask, - /// The original URI string that was parsed - pub original_uri: String, - /// The chain identifier (e.g., "mainnet", "testnet") - pub chain_specifier: String, -} -``` - -### GravityTask -```rust -pub enum GravityTask { - /// Monitor event task, contains a Filter object that can be directly used with Alloy - MonitorEvent(Filter), - /// Monitor block head task - MonitorBlockHead, - /// Monitor storage slot task - MonitorStorage { account: Address, slot: B256 }, - /// Monitor account activity task (abstract layer) - MonitorAccount { address: Address, activity_type: AccountActivityType }, -} -``` - -### ObserveState -```rust -pub struct ObserveState { - /// The block number at which the observation was made - pub block_number: u64, - /// The actual observed value (block, events, storage slot, or none) - pub observed_value: ObservedValue, - /// Chain timestamp to ensure consistency - pub timestamp: u64, - /// OnChain version for tracking changes - pub version: u64, -} -``` - -### ObservedValue -```rust -pub enum ObservedValue { - /// Observed block information - Block { block_hash: B256, block_number: u64 }, - /// Observed event logs - Events { logs: Vec }, - /// Observed storage slot value - StorageSlot { slot: B256, value: B256 }, - /// No observation made - None, -} -``` - -### EventLog -```rust -pub struct EventLog { - /// Contract address that emitted the event - pub address: Address, - /// Event topics (indexed parameters) - pub topics: Vec, - /// Event data (non-indexed parameters) - pub data: Vec, - /// Block number where the event occurred - pub block_number: u64, - /// Transaction hash that triggered the event - pub transaction_hash: B256, - /// Log index within the transaction - pub log_index: u64, -} -``` - -## URI Format Examples - -### Block Monitoring -```rust -// Monitor latest block -"gravity://mainnet/block?strategy=head" -``` - -### Event Monitoring -```rust -// Monitor specific contract events -"gravity://mainnet/event?address=0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48&topic0=0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" - -// Monitor events with multiple topics -"gravity://mainnet/event?address=0x...&topic0=0x...&topic1=0x..." - -// Monitor events with OR conditions -"gravity://mainnet/event?topic0=0x...,0x..." - -// Monitor events from a specific block number -"gravity://mainnet/event?address=0x...&topic0=0x...&fromBlock=1500" - -// Monitor events from block tags -"gravity://mainnet/event?address=0x...&topic0=0x...&fromBlock=latest" -"gravity://mainnet/event?address=0x...&topic0=0x...&fromBlock=finalized" -"gravity://mainnet/event?address=0x...&topic0=0x...&fromBlock=earliest" -``` - -### Storage Monitoring -```rust -// Monitor storage slot changes -"gravity://mainnet/storage?account=0x123456789abcdef123456789abcdef1234567890&slot=0x0000000000000000000000000000000000000000000000000000000000000001" +```text +gravity://3//price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=&continuous=true&decimals=8&aggregationMode=2&minSourceCount=1&minTotalWeight=1&maxStaleness=180000&graceMs=120000 ``` -### Account Activity Monitoring -```rust -// Monitor ERC20 transfers for specific address -"gravity://mainnet/account/0x123456789abcdef123456789abcdef1234567890/activity?type=erc20_transfer" +`bucketStartMs` identifies the first delivery bucket. Delivery nonce `n` maps +to that start plus `(n - 1) * intervalMs`. Validators request one exact closed +bucket from `/fapi/v1/indexPriceKlines` and reject mismatched timestamps. -// Monitor all transactions for specific address -"gravity://mainnet/account/0x123456789abcdef123456789abcdef1234567890/activity?type=all_transactions" -``` - -## Event Filter Parameters +The base URL comes from validator-local relayer JSON. It is not included in the +URI. Public `indexPriceKlines` requests do not use `BINANCE_API_KEY` or +`BINANCE_SECRET_KEY`. -### fromBlock Parameter -The `fromBlock` parameter allows you to specify the starting block for event monitoring: +## Polymarket mirror -```rust -// Start from a specific block number -"gravity://mainnet/event?address=0x...&topic0=0x...&fromBlock=1500" - -// Start from block tags -"gravity://mainnet/event?address=0x...&topic0=0x...&fromBlock=latest" // Latest block -"gravity://mainnet/event?address=0x...&topic0=0x...&fromBlock=finalized" // Finalized block -"gravity://mainnet/event?address=0x...&topic0=0x...&fromBlock=earliest" // Genesis block +```text +gravity://6//polymarket_settlement?ctf=
&condition=&fromBlock=&maxBlocksPerPoll=1000 ``` -**Supported fromBlock values:** -- **Block number**: Any positive integer (e.g., `1500`, `1000000`) -- **latest**: Start from the latest block -- **finalized**: Start from the latest finalized block (recommended for production) -- **earliest**: Start from the genesis block (block 0) - -**Note**: If `fromBlock` is not specified, the relayer will start monitoring from the current finalized block by default. - -## Advanced Usage - -### Multiple URI Management -```rust -use reth_pipe_exec_layer_relayer::RelayerManager; -use reth_tracing::{LayerInfo, LogFormat, RethTracer, Tracer}; -use tracing::level_filters::LevelFilter; -use tracing::info; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Initialize tracing - let tracer = RethTracer::new().with_stdout(LayerInfo::new( - LogFormat::Terminal, - LevelFilter::INFO.to_string(), - "trace".to_string(), - None, - )); - tracer.init().unwrap(); - - let manager = RelayerManager::new(); - - let uris = vec![ - "gravity://mainnet/block?strategy=head", - "gravity://mainnet/event?address=0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48&topic0=0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", - "gravity://mainnet/storage?account=0x123456789abcdef123456789abcdef1234567890&slot=0x0", - ]; - - // Add multiple URIs (no initial state required) - for uri in &uris { - match manager.add_uri(uri, "https://rpc.ankr.com/eth").await { - Ok(()) => info!("Successfully added URI: {}", uri), - Err(e) => info!("Failed to add URI {}: {}", uri, e), - } - } - - // Poll all URIs - for uri in &uris { - match manager.poll_uri(uri).await { - Ok(state) => info!("URI {}: {:?}", uri, state), - Err(e) => info!("Error polling {}: {}", uri, e), - } - } - - Ok(()) -} -``` - -### Batch URI Parsing -```rust -use reth_pipe_exec_layer_relayer::UriParser; -use tracing::info; - -let parser = UriParser::new(); -let uris = vec![ - "gravity://mainnet/block?strategy=head".to_string(), - "gravity://mainnet/event?address=0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string(), -]; - -for uri in uris { - match parser.parse(&uri) { - Ok(task) => { - info!("Parsed URI: {} -> Chain: {}, Task: {:?}", - uri, task.chain_specifier, task.task); - } - Err(e) => { - info!("Failed to parse URI {}: {}", uri, e); - } - } -} -``` +The source checks RPC chain id `137`, reads only finalized blocks, and filters +one reviewed condition. A mirror task without `condition` is rejected. A +malformed filtered log fails closed without advancing the cursor. -## Error Handling - -The library uses `anyhow::Result` for error handling. Common error scenarios include: - -- Invalid URI format -- Unsupported chain specifiers -- Missing required parameters -- Invalid Ethereum addresses or topics -- RPC connection failures -- Network timeouts - -## Performance Considerations - -1. **RPC Rate Limiting**: The library includes built-in retry logic with exponential backoff -2. **Finalized Blocks**: By default, only finalized blocks are processed to ensure consistency -3. **Cursor Management**: Efficient cursor tracking prevents reprocessing of already seen data -4. **Batch Operations**: Support for batch URI parsing and management - -## Dependencies - -- `alloy-primitives`: Ethereum primitives and types -- `alloy-rpc-types`: RPC types and filters -- `tokio`: Async runtime -- `anyhow`: Error handling -- `tracing`: Logging and debugging -- `serde`: Serialization/deserialization -- `reth-tracing`: Reth tracing utilities - -## Running Examples +## Local verification ```bash -# Run the basic usage example -cargo run --example new_usage - -# Run tests -cargo test - -# Generate documentation -cargo doc --open +cargo test -p reth-pipe-exec-layer-relayer +cargo test -p reth-pipe-exec-layer-ext-v2 --lib jwk_oracle ``` -## Notes - -1. Ensure you provide valid Ethereum RPC endpoints -2. Storage slot monitoring requires RPC support for `eth_getStorageAt` method -3. Consider using longer polling intervals in production to avoid excessive RPC calls -4. The library automatically handles retries and backoff for failed requests -5. All operations are async and should be run in a Tokio runtime -6. Tracing initialization is required for proper logging - -## TODO - -- [ ] Add WebSocket support for real-time updates -- [ ] Support for more event filtering options -- [ ] Add metrics and monitoring capabilities -- [ ] Implement connection pooling for multiple RPC endpoints \ No newline at end of file +Ignored live tests require explicit public network access and are not part of +the normal test gate. diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/blockchain_source.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/blockchain_source.rs index 0d4af2bd93..1dc0f01551 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/blockchain_source.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/blockchain_source.rs @@ -363,7 +363,7 @@ mod tests { const ANVIL_PORTAL_ADDRESS: &str = "0x0f761B1B3c1aC9232C9015A7276692560aD6a05F"; /// GBridgeSender address on local Anvil (deterministic, nonce 2) - const ANVIL_SENDER_ADDRESS: &str = "0x3fc870008B1cc26f3614F14a726F8077227CA2c3"; + const _ANVIL_SENDER_ADDRESS: &str = "0x3fc870008B1cc26f3614F14a726F8077227CA2c3"; /// Anvil RPC URL const ANVIL_RPC_URL: &str = "https://sepolia.drpc.org"; @@ -425,6 +425,7 @@ mod tests { } #[tokio::test] + #[ignore = "requires a configured external RPC endpoint and seeded events"] async fn test_poll_anvil_events() { use crate::eth_client::EthHttpCli; diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/data_source.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/data_source.rs index 1cc9fdd709..060490902c 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/data_source.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/data_source.rs @@ -7,7 +7,10 @@ use alloy_primitives::{Bytes, U256}; use anyhow::Result; use async_trait::async_trait; -use crate::blockchain_source::BlockchainEventSource; +use crate::{ + blockchain_source::BlockchainEventSource, + polymarket_settlement_source::PolymarketSettlementSource, price_feed_source::PriceFeedSource, +}; /// Data returned by oracle data sources /// @@ -47,6 +50,12 @@ pub trait OracleDataSource: Send + Sync { pub mod source_types { /// Blockchain cross-chain events (e.g., GravityPortal.MessageSent) pub const BLOCKCHAIN: u32 = 0; + + /// External price feed resolver payloads (sourceType=3) + pub const PRICE_FEED: u32 = 3; + + /// Polygon Polymarket/CTF settlement mirror payloads + pub const POLYMARKET_SETTLEMENT: u32 = 6; } /// Extensible enum for runtime dispatch of data sources @@ -54,11 +63,57 @@ pub mod source_types { /// New source types can be added by: /// 1. Adding a new variant here /// 2. Implementing the source struct -/// 3. Adding a case in DataSourceFactory +/// 3. Adding a case in `OracleRelayerManager::create_source_from_task` #[derive(Debug)] pub enum DataSourceKind { /// Blockchain cross-chain events (sourceType=0) Blockchain(BlockchainEventSource), + /// Price feed observations (sourceType=3) + PriceFeed(PriceFeedSource), + /// Polymarket CTF settlement observations (sourceType=6) + PolymarketSettlement(PolymarketSettlementSource), +} + +impl DataSourceKind { + pub(crate) async fn last_nonce(&self) -> Option { + match self { + Self::Blockchain(source) => source.last_nonce().await, + Self::PriceFeed(source) => source.last_nonce().await, + Self::PolymarketSettlement(source) => source.last_nonce().await, + } + } + + pub(crate) async fn last_nonce_block(&self) -> Option { + match self { + Self::Blockchain(source) => source.last_nonce_block().await, + Self::PriceFeed(source) => source.last_nonce_block().await, + Self::PolymarketSettlement(source) => source.last_nonce_block().await, + } + } + + pub(crate) async fn fast_forward(&self, nonce: u128, block: u64) { + match self { + Self::Blockchain(source) => source.fast_forward(nonce, block).await, + Self::PriceFeed(source) => source.fast_forward(nonce, block).await, + Self::PolymarketSettlement(source) => source.fast_forward(nonce, block).await, + } + } + + pub(crate) fn cursor(&self) -> u64 { + match self { + Self::Blockchain(source) => source.cursor(), + Self::PriceFeed(source) => source.cursor(), + Self::PolymarketSettlement(source) => source.cursor(), + } + } + + pub(crate) fn source_id_u64(&self) -> u64 { + match self { + Self::Blockchain(source) => source.chain_id(), + Self::PriceFeed(source) => source.feed_id(), + Self::PolymarketSettlement(source) => source.mirror_id(), + } + } } #[async_trait] @@ -66,18 +121,24 @@ impl OracleDataSource for DataSourceKind { fn source_type(&self) -> u32 { match self { DataSourceKind::Blockchain(_) => source_types::BLOCKCHAIN, + DataSourceKind::PriceFeed(_) => source_types::PRICE_FEED, + DataSourceKind::PolymarketSettlement(_) => source_types::POLYMARKET_SETTLEMENT, } } fn source_id(&self) -> U256 { match self { DataSourceKind::Blockchain(s) => s.source_id(), + DataSourceKind::PriceFeed(s) => s.source_id(), + DataSourceKind::PolymarketSettlement(s) => s.source_id(), } } async fn poll(&self) -> Result> { match self { DataSourceKind::Blockchain(s) => s.poll().await, + DataSourceKind::PriceFeed(s) => s.poll().await, + DataSourceKind::PolymarketSettlement(s) => s.poll().await, } } } diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/eth_client.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/eth_client.rs index 6e88fa3571..f9eea5b40b 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/eth_client.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/eth_client.rs @@ -50,12 +50,15 @@ impl EthHttpCli { /// # Errors /// * Returns an error if the URL cannot be parsed or client cannot be built pub fn new(rpc_url: &str) -> Result { - debug!("Creating EthHttpCli for URL: {}", rpc_url); + debug!("Creating Ethereum HTTP client"); - let url = - Url::parse(rpc_url).with_context(|| format!("Failed to parse RPC URL: {}", rpc_url))?; + let url = Url::parse(rpc_url).with_context(|| "Failed to parse RPC URL")?; - let client_builder = ClientBuilder::new().no_proxy().use_rustls_tls(); + let client_builder = ClientBuilder::new() + .no_proxy() + .use_rustls_tls() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)); let client = client_builder.build().with_context(|| "Failed to build HTTP client")?; let provider: RootProvider = @@ -71,6 +74,13 @@ impl EthHttpCli { .with_context(|| "Failed to get logs with filter") } + /// Returns the chain id reported by the configured RPC endpoint. + pub async fn get_chain_id(&self) -> Result { + self.retry_with_backoff(|| async { self.provider.get_chain_id().await }) + .await + .with_context(|| "Failed to get chain id") + } + /// Gets the latest finalized block number pub async fn get_finalized_block_number(&self) -> Result { self.retry_with_backoff(|| async { diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/factory.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/factory.rs deleted file mode 100644 index d91422d660..0000000000 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/factory.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Data Source Factory -//! -//! Creates data sources from OracleTaskConfig.config bytes based on sourceType. - -use crate::{ - blockchain_source::BlockchainEventSource, - data_source::{source_types, DataSourceKind}, -}; -use alloy_primitives::{Bytes, U256}; -use anyhow::{anyhow, Result}; - -/// Factory for creating data sources from chain configuration -/// -/// This factory dispatches to the appropriate source implementation based on -/// the sourceType from OracleTaskConfig. -#[derive(Debug, Clone, Copy, Default)] -pub struct DataSourceFactory; - -impl DataSourceFactory { - /// Create a data source from chain configuration - /// - /// # Arguments - /// * `source_type` - The source type (0=BLOCKCHAIN, etc.) - /// * `source_id` - The source identifier (chain ID, etc.) - /// * `config` - ABI-encoded configuration from OracleTaskConfig - /// - /// # Returns - /// * `Result` - The created data source or an error - pub async fn create( - source_type: u32, - source_id: U256, - config: Bytes, - ) -> Result { - match source_type { - source_types::BLOCKCHAIN => { - let source = BlockchainEventSource::from_config(source_id, &config).await?; - Ok(DataSourceKind::Blockchain(source)) - } - _ => Err(anyhow!("Unknown source type: {}", source_type)), - } - } -} - -#[cfg(test)] -mod tests { - use crate::OracleDataSource; - - use super::*; - use alloy_primitives::address; - use alloy_sol_types::SolValue; - - #[tokio::test] - async fn test_create_blockchain_source() { - // Encode config: (rpcUrl, portalAddress, startBlock) - let config = ( - "https://rpc.example.com".to_string(), - address!("5FbDB2315678afecb367f032d93F642f64180aa3"), - 0u64, - ) - .abi_encode(); - - let result = DataSourceFactory::create( - source_types::BLOCKCHAIN, - U256::from(1), // Ethereum chain ID - Bytes::from(config), - ) - .await; - - assert!(result.is_ok()); - let source = result.unwrap(); - assert_eq!(source.source_type(), source_types::BLOCKCHAIN); - assert_eq!(source.source_id(), U256::from(1)); - } - - #[tokio::test] - async fn test_create_unknown_source_type() { - let result = DataSourceFactory::create( - 99, // Unknown type - U256::from(1), - Bytes::new(), - ) - .await; - - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Unknown source type")); - } -} diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/lib.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/lib.rs index deed4d3596..533e7a7e84 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/lib.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/lib.rs @@ -20,8 +20,11 @@ pub mod data_source; /// Blockchain event source (GravityPortal.MessageSent) pub mod blockchain_source; -/// Factory for creating data sources -pub mod factory; +/// Price feed source +pub mod price_feed_source; + +/// Polymarket CTF settlement mirror source +pub mod polymarket_settlement_source; /// Oracle relayer manager (URI-keyed interface) pub mod oracle_manager; @@ -42,6 +45,7 @@ pub mod persistence; pub use blockchain_source::BlockchainEventSource; 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 polymarket_settlement_source::PolymarketSettlementSource; +pub use price_feed_source::PriceFeedSource; pub use uri_parser::{parse_oracle_uri, ParsedOracleTask}; diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/oracle_manager.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/oracle_manager.rs index 3d95de9877..a8ae9e5bf2 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/oracle_manager.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/oracle_manager.rs @@ -6,6 +6,8 @@ use crate::{ blockchain_source::BlockchainEventSource, data_source::{source_types, DataSourceKind, OracleDataSource}, persistence::{load_state_if_exists, state_file_path, RelayerState, SourceState}, + polymarket_settlement_source::PolymarketSettlementSource, + price_feed_source::PriceFeedSource, uri_parser::{parse_oracle_uri, ParsedOracleTask}, }; use anyhow::{anyhow, Result}; @@ -19,11 +21,21 @@ pub use gravity_api_types::{on_chain_config::jwks::JWKStruct, relayer::PollResul /// Startup scenario for determining initial source state /// /// When a data source is added, we need to determine where to start scanning. -/// This enum captures the 4 possible scenarios based on persisted and on-chain state. +/// This enum captures the possible scenarios based on persisted and on-chain state. #[derive(Debug)] enum StartupScenario { /// Persisted state exists but is stale - fast-forward to on-chain state FastForward { onchain_nonce: u128, onchain_block: u64, persisted_nonce: u128 }, + /// Persisted state is ahead of NativeOracle, so it may only represent data + /// fetched locally and not yet accepted on-chain. Rewind to the last + /// confirmed on-chain record. + RollbackToOnChain { + onchain_nonce: u128, + onchain_block: u64, + persisted_nonce: u128, + persisted_cursor: u64, + restart_cursor: u64, + }, /// Persisted state is valid - use it for fast restart Restore { cursor: u64, nonce: u128 }, /// No persisted state, but on-chain has data - sync from on-chain @@ -46,6 +58,13 @@ impl StartupScenario { onchain_block, persisted_nonce: state.last_nonce as u128, }, + Some(state) if state.last_nonce as u128 > onchain_nonce => Self::RollbackToOnChain { + onchain_nonce, + onchain_block, + persisted_nonce: state.last_nonce as u128, + persisted_cursor: state.cursor_block, + restart_cursor: if onchain_nonce > 0 { onchain_block } else { default_from_block }, + }, Some(state) => { Self::Restore { cursor: state.cursor_block, nonce: state.last_nonce as u128 } } @@ -60,6 +79,9 @@ impl StartupScenario { Self::FastForward { onchain_nonce, onchain_block, .. } => { (onchain_block, onchain_nonce) } + Self::RollbackToOnChain { onchain_nonce, restart_cursor, .. } => { + (restart_cursor, onchain_nonce) + } Self::Restore { cursor, nonce } => (cursor, nonce), Self::ColdStartWithSync { onchain_nonce, onchain_block } => { (onchain_block, onchain_nonce) @@ -81,6 +103,24 @@ impl StartupScenario { "Persisted state is stale, fast-forwarding to on-chain state" ); } + Self::RollbackToOnChain { + onchain_nonce, + onchain_block, + persisted_nonce, + persisted_cursor, + restart_cursor, + } => { + warn!( + target: "oracle_manager", + uri, + persisted_nonce, + persisted_cursor, + onchain_nonce, + onchain_block, + restart_cursor, + "Persisted state is ahead of NativeOracle; rolling back to confirmed on-chain progress" + ); + } Self::Restore { cursor, nonce } => { info!( target: "oracle_manager", @@ -212,6 +252,25 @@ impl OracleRelayerManager { Ok(DataSourceKind::Blockchain(source)) } + source_types::PRICE_FEED => { + let source = PriceFeedSource::from_task_with_reconciled_cursor( + task, + latest_onchain_nonce, + Some(rpc_url), + persisted_cursor, + )?; + Ok(DataSourceKind::PriceFeed(source)) + } + source_types::POLYMARKET_SETTLEMENT => { + let source = PolymarketSettlementSource::from_task( + task, + rpc_url, + latest_onchain_nonce, + persisted_cursor.unwrap_or(task.from_block()), + ) + .await?; + Ok(DataSourceKind::PolymarketSettlement(source)) + } _ => Err(anyhow!("Unknown source type: {}", task.source_type)), } } @@ -234,39 +293,30 @@ impl OracleRelayerManager { let sources = self.sources.read().await; let source = sources.get(uri).ok_or_else(|| anyhow!("Source not found: {}", uri))?; - // Reconcile with on-chain state before polling + // Reconcile with on-chain state before polling. if let (Some(onchain_nonce), Some(onchain_block)) = (onchain_nonce, onchain_block_number) { - match source.as_ref() { - DataSourceKind::Blockchain(s) => { - let current_nonce = s.last_nonce().await.unwrap_or(0); - if onchain_nonce > current_nonce { - info!( - target: "oracle_manager", - uri = uri, - local_nonce = current_nonce, - onchain_nonce = onchain_nonce, - onchain_block = onchain_block, - "On-chain ahead of local, fast-forwarding" - ); - s.fast_forward(onchain_nonce, onchain_block).await; - } - } + let current_nonce = source.last_nonce().await.unwrap_or(0); + if onchain_nonce > current_nonce { + info!( + target: "oracle_manager", + uri, + local_nonce = current_nonce, + onchain_nonce, + onchain_block, + "On-chain state is ahead of local source; fast-forwarding" + ); + source.fast_forward(onchain_nonce, onchain_block).await; } } let data = source.poll().await?; // Get nonce, cursor, and source info - let (nonce, last_nonce_block, max_block_number, source_type, source_id) = - match source.as_ref() { - DataSourceKind::Blockchain(s) => ( - s.last_nonce().await, - s.last_nonce_block().await, - s.cursor(), - source_types::BLOCKCHAIN, - s.chain_id(), - ), - }; + let nonce = source.last_nonce().await; + let last_nonce_block = source.last_nonce_block().await; + let max_block_number = source.cursor(); + let source_type = source.source_type(); + let source_id = source.source_id_u64(); let jwk_structs: Vec = data .iter() @@ -306,10 +356,11 @@ impl OracleRelayerManager { /// Update in-memory state and persist to disk. /// /// Writes to disk first (via a temporary clone) so that on the success - /// path the on-disk state is never behind in-memory state. If the disk - /// write fails, in-memory state is still advanced to avoid duplicate - /// delivery in the running process — only a subsequent crash would - /// replay events from the stale checkpoint. + /// path the on-disk state is never behind in-memory state. This state + /// tracks data returned to consensus, not data accepted by `NativeOracle`; + /// startup reconciliation rolls back any checkpoint that is ahead of the + /// confirmed on-chain nonce. If the disk write fails, in-memory state is + /// still advanced to avoid duplicate delivery in the running process. async fn update_and_save_state( &self, uri: &str, @@ -331,7 +382,6 @@ impl OracleRelayerManager { warn!( target: "oracle_manager", error = ?e, - path = ?path, "Failed to persist relayer state; a crash may replay events from the last checkpoint" ); } @@ -360,3 +410,85 @@ impl OracleRelayerManager { self.sources.read().await.keys().cloned().collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn price_uri() -> &'static str { + "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-a:2000:10000000000:1,source-b:2000:10200000000:2,source-c:2000:9800000000:1" + } + + fn polymarket_uri() -> &'static str { + "gravity://6/42/polymarket_settlement?ctf=0x4D97DCd97eC945f40cF65F87097ACe5EA0476045&fromBlock=50000000&condition=0x1111111111111111111111111111111111111111111111111111111111111111" + } + + #[tokio::test] + async fn test_add_and_poll_price_feed_uri() { + let datadir = tempfile::tempdir().unwrap(); + let manager = OracleRelayerManager::new(datadir.path().to_path_buf()); + + manager.add_uri(price_uri(), "", 0, 0).await.unwrap(); + assert!(manager.has_uri(price_uri()).await); + + let first = manager.poll_uri(price_uri(), None, None).await.unwrap(); + assert!(first.updated); + assert_eq!(first.nonce, Some(1)); + assert_eq!(first.max_block_number, 2010); + assert_eq!(first.jwk_structs.len(), 1); + assert_eq!(first.jwk_structs[0].type_name, source_types::PRICE_FEED.to_string()); + assert!(!first.jwk_structs[0].data.is_empty()); + + let second = manager.poll_uri(price_uri(), None, None).await.unwrap(); + assert!(!second.updated); + assert_eq!(second.jwk_structs.len(), 0); + } + + #[tokio::test] + async fn test_add_polymarket_settlement_uri() { + let datadir = tempfile::tempdir().unwrap(); + let manager = OracleRelayerManager::new(datadir.path().to_path_buf()); + + manager.add_uri(polymarket_uri(), "http://localhost:8545", 0, 0).await.unwrap(); + assert!(manager.has_uri(polymarket_uri()).await); + } + + #[test] + fn test_startup_rolls_back_persisted_state_ahead_of_onchain() { + let mut state = RelayerState::new(); + state.update( + polymarket_uri(), + source_types::POLYMARKET_SETTLEMENT, + 42, + 5, + 50_000_010, + 50_000_020, + ); + + let scenario = + StartupScenario::determine(state.get(polymarket_uri()), 3, 50_000_007, 50_000_000); + let (cursor, nonce) = scenario.into_init_params(); + + assert_eq!(cursor, 50_000_007); + assert_eq!(nonce, 3); + } + + #[test] + fn test_startup_rolls_back_to_config_when_onchain_empty() { + let mut state = RelayerState::new(); + state.update( + polymarket_uri(), + source_types::POLYMARKET_SETTLEMENT, + 42, + 2, + 50_000_010, + 50_000_020, + ); + + let scenario = StartupScenario::determine(state.get(polymarket_uri()), 0, 0, 50_000_000); + let (cursor, nonce) = scenario.into_init_params(); + + assert_eq!(cursor, 50_000_000); + assert_eq!(nonce, 0); + } +} diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/persistence.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/persistence.rs index f8a12e122d..4434fd5be0 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/persistence.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/persistence.rs @@ -51,11 +51,10 @@ impl RelayerState { /// Load state from a file pub fn load(path: &Path) -> Result { - let content = fs::read_to_string(path) - .with_context(|| format!("Failed to read state file: {}", path.display()))?; + let content = fs::read_to_string(path).context("Failed to read relayer state file")?; - let state: Self = serde_json::from_str(&content) - .with_context(|| format!("Failed to parse state file: {}", path.display()))?; + let state: Self = + serde_json::from_str(&content).context("Failed to parse relayer state file")?; if state.version != SCHEMA_VERSION { // Return a fresh state instead of loading incompatible data. @@ -68,7 +67,7 @@ impl RelayerState { return Ok(Self::new()); } - debug!("Loaded relayer state with {} sources from {}", state.sources.len(), path.display()); + debug!("Loaded relayer state with {} sources", state.sources.len()); Ok(state) } @@ -77,22 +76,18 @@ impl RelayerState { pub fn save(&self, path: &Path) -> Result<()> { // Ensure parent directory exists if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + fs::create_dir_all(parent).context("Failed to create relayer state directory")?; } let content = serde_json::to_string_pretty(self).context("Failed to serialize state")?; // Write to temp file first, then rename for atomicity let temp_path = path.with_extension("json.tmp"); - fs::write(&temp_path, &content) - .with_context(|| format!("Failed to write temp file: {}", temp_path.display()))?; + fs::write(&temp_path, &content).context("Failed to write temporary relayer state file")?; - fs::rename(&temp_path, path).with_context(|| { - format!("Failed to rename {} to {}", temp_path.display(), path.display()) - })?; + fs::rename(&temp_path, path).context("Failed to commit relayer state file")?; - debug!("Saved relayer state to {}", path.display()); + debug!("Saved relayer state"); Ok(()) } @@ -135,7 +130,7 @@ pub fn state_file_path(datadir: &Path) -> PathBuf { pub fn load_state_if_exists(datadir: &Path) -> Option { let path = state_file_path(datadir); if !path.exists() { - info!("No existing relayer state at {}", path.display()); + info!("No existing relayer state"); return None; } diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/polymarket_settlement_source.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/polymarket_settlement_source.rs new file mode 100644 index 0000000000..6ae75057bd --- /dev/null +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/polymarket_settlement_source.rs @@ -0,0 +1,785 @@ +//! Polymarket settlement mirror source. +//! +//! This source watches finalized Polygon logs from the Conditional Tokens +//! Framework (CTF) and mirrors `ConditionResolution` events into the same +//! UnsupportedJWK bytes path used by other Gravity oracle sources. + +use crate::{ + data_source::{source_types, OracleData, OracleDataSource}, + eth_client::EthHttpCli, + uri_parser::ParsedOracleTask, +}; +use alloy_primitives::{Address, Bytes, B256, U256}; +use alloy_rpc_types::{Filter, Log}; +use alloy_sol_macro::sol; +use alloy_sol_types::{SolEvent, SolValue}; +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use std::sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, +}; +use tokio::sync::Mutex; +use tracing::{debug, info}; + +sol! { + /// CTF condition resolution emitted by Gnosis Conditional Tokens. + event ConditionResolution( + bytes32 indexed conditionId, + address indexed oracle, + bytes32 indexed questionId, + uint256 outcomeSlotCount, + uint256[] payoutNumerators + ); + + struct PolymarketSettlementPayloadSol { + uint256 mirrorId; + uint256 polygonChainId; + address ctf; + address oracle; + bytes32 conditionId; + bytes32 questionId; + uint256 outcomeSlotCount; + uint256[] payoutNumerators; + bytes32 txHash; + uint256 logIndex; + uint8 settlementKind; + } +} + +const CTF_CONDITION_RESOLUTION: u8 = 1; +const DEFAULT_POLYGON_CHAIN_ID: u64 = 137; +const DEFAULT_MAX_BLOCKS_PER_POLL: u64 = 1_000; +const MAX_BLOCKS_PER_POLL: u64 = 10_000; +const MAX_OUTCOME_SLOT_COUNT: usize = 32; + +/// Last CTF settlement returned to the caller. +#[derive(Debug, Clone, Copy, Default)] +struct LastSettlement { + nonce: u128, + block: u64, +} + +impl LastSettlement { + fn is_initialized(self) -> bool { + self.nonce > 0 + } +} + +/// A canonical CTF settlement observation decoded from Polygon logs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolymarketSettlementObservation { + /// NativeOracle sourceId for this mirror. + pub mirror_id: u64, + /// Source chain id, normally Polygon PoS mainnet `137`. + pub polygon_chain_id: u64, + /// CTF contract which emitted the settlement. + pub ctf: Address, + /// UMA adapter / oracle address recorded in CTF. + pub oracle: Address, + /// CTF condition id. + pub condition_id: B256, + /// UMA / CTF question id. + pub question_id: B256, + /// Number of outcome slots in the CTF condition. + pub outcome_slot_count: U256, + /// Final payout vector. + pub payout_numerators: Vec, + /// Transaction hash containing this settlement log. + pub tx_hash: B256, + /// Log index in the source block. + pub log_index: u64, + /// Source block number. + pub block_number: u64, +} + +impl PolymarketSettlementObservation { + fn resolver_payload(&self) -> Vec { + PolymarketSettlementPayloadSol { + mirrorId: U256::from(self.mirror_id), + polygonChainId: U256::from(self.polygon_chain_id), + ctf: self.ctf, + oracle: self.oracle, + conditionId: self.condition_id, + questionId: self.question_id, + outcomeSlotCount: self.outcome_slot_count, + payoutNumerators: self.payout_numerators.clone(), + txHash: self.tx_hash, + logIndex: U256::from(self.log_index), + settlementKind: CTF_CONDITION_RESOLUTION, + } + .abi_encode() + } + + fn wrapped_payload(&self, delivery_nonce: u128) -> Bytes { + Bytes::from(SolValue::abi_encode(&( + delivery_nonce, + U256::from(self.block_number), + self.resolver_payload().as_slice(), + ))) + } +} + +/// Polygon Polymarket settlement mirror for `sourceType=6`. +#[derive(Debug)] +pub struct PolymarketSettlementSource { + mirror_id: u64, + polygon_chain_id: u64, + rpc_client: Arc, + ctf_address: Address, + condition_id: B256, + max_blocks_per_poll: u64, + chain_verified: AtomicBool, + cursor: AtomicU64, + last_settlement: Mutex, +} + +impl PolymarketSettlementSource { + /// Create a source from a `gravity://6//polymarket_settlement` + /// URI. + pub async fn from_task( + task: &ParsedOracleTask, + rpc_url: &str, + latest_onchain_nonce: u128, + cursor: u64, + ) -> Result { + if task.source_type != source_types::POLYMARKET_SETTLEMENT { + return Err(anyhow!( + "PolymarketSettlementSource requires sourceType={}", + source_types::POLYMARKET_SETTLEMENT + )); + } + + let ctf_address = parse_address(task, "ctf")?; + let polygon_chain_id = parse_optional(task, "chainId")?.unwrap_or(DEFAULT_POLYGON_CHAIN_ID); + let condition_id = parse_required_b256(task, "condition")?; + if polygon_chain_id != DEFAULT_POLYGON_CHAIN_ID { + return Err(anyhow!( + "Polymarket settlement chainId must be {}", + DEFAULT_POLYGON_CHAIN_ID + )); + } + if ctf_address == Address::ZERO { + return Err(anyhow!("Polymarket settlement ctf cannot be zero")); + } + if condition_id == B256::ZERO { + return Err(anyhow!("Polymarket settlement condition cannot be zero")); + } + let max_blocks_per_poll = + parse_optional(task, "maxBlocksPerPoll")?.unwrap_or(DEFAULT_MAX_BLOCKS_PER_POLL); + if max_blocks_per_poll == 0 || max_blocks_per_poll > MAX_BLOCKS_PER_POLL { + return Err(anyhow!("maxBlocksPerPoll must be between 1 and {}", MAX_BLOCKS_PER_POLL)); + } + + info!( + target: "polymarket_settlement_source", + mirror_id = task.source_id, + polygon_chain_id, + ctf_address = ?ctf_address, + condition_id = ?condition_id, + max_blocks_per_poll, + cursor, + latest_onchain_nonce, + "Created PolymarketSettlementSource" + ); + + Ok(Self { + mirror_id: task.source_id, + polygon_chain_id, + rpc_client: Arc::new(EthHttpCli::new(rpc_url)?), + ctf_address, + condition_id, + max_blocks_per_poll, + chain_verified: AtomicBool::new(false), + cursor: AtomicU64::new(cursor), + last_settlement: Mutex::new(LastSettlement { + nonce: latest_onchain_nonce, + block: cursor, + }), + }) + } + + /// Current block cursor used for relayer persistence. + pub fn cursor(&self) -> u64 { + self.cursor.load(Ordering::Relaxed) + } + + /// Mirror identifier used as NativeOracle sourceId. + pub fn mirror_id(&self) -> u64 { + self.mirror_id + } + + /// Maximum finalized Polygon blocks scanned in one poll. + pub fn max_blocks_per_poll(&self) -> u64 { + self.max_blocks_per_poll + } + + /// Last nonce returned or reconciled from NativeOracle. + pub async fn last_nonce(&self) -> Option { + let state = *self.last_settlement.lock().await; + state.is_initialized().then_some(state.nonce) + } + + /// Block number associated with the last returned or reconciled event. + pub async fn last_nonce_block(&self) -> Option { + let state = *self.last_settlement.lock().await; + state.is_initialized().then_some(state.block) + } + + /// Advance local state to match an already-recorded on-chain settlement. + pub async fn fast_forward(&self, nonce: u128, block: u64) { + *self.last_settlement.lock().await = LastSettlement { nonce, block }; + self.cursor.store(block, Ordering::Relaxed); + } + + fn filter(&self, from_block: u64, to_block: u64) -> Filter { + Filter::new() + .address(self.ctf_address) + .event_signature(ConditionResolution::SIGNATURE_HASH) + .from_block(from_block) + .to_block(to_block) + .topic1(self.condition_id) + } + + fn decode_log(&self, log: &Log) -> Result { + decode_condition_resolution_log( + log, + self.mirror_id, + self.polygon_chain_id, + self.ctf_address, + self.condition_id, + ) + } + + async fn ensure_polygon_chain(&self) -> Result<()> { + if self.chain_verified.load(Ordering::Acquire) { + return Ok(()); + } + + let actual_chain_id = self.rpc_client.get_chain_id().await?; + if actual_chain_id != self.polygon_chain_id { + return Err(anyhow!( + "Polymarket RPC chain id mismatch: expected {}, got {}", + self.polygon_chain_id, + actual_chain_id + )); + } + self.chain_verified.store(true, Ordering::Release); + Ok(()) + } +} + +#[async_trait] +impl OracleDataSource for PolymarketSettlementSource { + fn source_type(&self) -> u32 { + source_types::POLYMARKET_SETTLEMENT + } + + fn source_id(&self) -> U256 { + U256::from(self.mirror_id) + } + + async fn poll(&self) -> Result> { + if self.last_settlement.lock().await.is_initialized() { + return Ok(vec![]); + } + self.ensure_polygon_chain().await?; + let cursor = self.cursor.load(Ordering::Relaxed); + let finalized_block = self.rpc_client.get_finalized_block_number().await?; + let scan_limit = cursor + .checked_add(self.max_blocks_per_poll) + .ok_or_else(|| anyhow!("Polymarket block cursor overflow"))?; + let to_block = std::cmp::min(scan_limit, finalized_block); + + if to_block <= cursor { + return Ok(vec![]); + } + + let from_block = + cursor.checked_add(1).ok_or_else(|| anyhow!("Polymarket block cursor overflow"))?; + let filter = self.filter(from_block, to_block); + debug!( + target: "polymarket_settlement_source", + mirror_id = self.mirror_id, + from_block, + to_block, + "Polling finalized Polygon CTF settlement logs" + ); + + let logs = self.rpc_client.get_logs(&filter).await?; + let mut observations = Vec::with_capacity(logs.len()); + + for log in logs { + observations.push(self.decode_log(&log)?); + } + + sort_and_dedup_observations(&mut observations); + if observations.len() > 1 { + return Err(anyhow!("multiple distinct settlements found for one Polymarket condition")); + } + + let starting_nonce = self.last_settlement.lock().await.nonce; + let results = observations_to_oracle_data(starting_nonce, &observations)?; + + self.cursor.store(to_block, Ordering::Relaxed); + + if let Some(last) = observations.last() { + *self.last_settlement.lock().await = LastSettlement { + nonce: results.last().map(|item| item.nonce).unwrap_or(starting_nonce), + block: last.block_number, + }; + } + + info!( + target: "polymarket_settlement_source", + mirror_id = self.mirror_id, + events_found = results.len(), + new_cursor = to_block, + "Poll completed" + ); + + Ok(results) + } +} + +fn sort_and_dedup_observations(observations: &mut Vec) { + observations.sort_by(|a, b| { + a.block_number + .cmp(&b.block_number) + .then(a.log_index.cmp(&b.log_index)) + .then_with(|| a.tx_hash.as_slice().cmp(b.tx_hash.as_slice())) + }); + observations.dedup_by(|a, b| { + a.block_number == b.block_number && a.log_index == b.log_index && a.tx_hash == b.tx_hash + }); +} + +fn observations_to_oracle_data( + starting_nonce: u128, + observations: &[PolymarketSettlementObservation], +) -> Result> { + observations + .iter() + .enumerate() + .map(|(idx, obs)| { + let offset = u128::try_from(idx) + .map_err(|_| anyhow!("Polymarket settlement batch exceeds u128"))? + .checked_add(1) + .ok_or_else(|| anyhow!("Polymarket settlement nonce overflow"))?; + let nonce = starting_nonce + .checked_add(offset) + .ok_or_else(|| anyhow!("Polymarket settlement nonce overflow"))?; + Ok(OracleData { nonce, payload: obs.wrapped_payload(nonce) }) + }) + .collect() +} + +fn decode_condition_resolution_log( + log: &Log, + mirror_id: u64, + polygon_chain_id: u64, + ctf_address: Address, + condition_id: B256, +) -> Result { + if log.removed { + return Err(anyhow!("finalized Polymarket settlement log cannot be removed")); + } + if log.address() != ctf_address { + return Err(anyhow!("Polymarket settlement log CTF address mismatch")); + } + + let decoded = ConditionResolution::decode_log_validate(&log.inner) + .map_err(|_| anyhow!("failed to decode filtered Polymarket settlement log"))?; + + let block_number = log + .block_number + .ok_or_else(|| anyhow!("Polymarket settlement log is missing block_number"))?; + let log_index = + log.log_index.ok_or_else(|| anyhow!("Polymarket settlement log is missing log_index"))?; + let tx_hash = log + .transaction_hash + .ok_or_else(|| anyhow!("Polymarket settlement log is missing transaction_hash"))?; + + let event = decoded.data; + if event.conditionId != condition_id { + return Err(anyhow!("Polymarket settlement log condition mismatch")); + } + if event.oracle == Address::ZERO || event.questionId == B256::ZERO { + return Err(anyhow!("Polymarket settlement log has zero oracle or questionId")); + } + + validate_payouts(event.outcomeSlotCount, &event.payoutNumerators)?; + + Ok(PolymarketSettlementObservation { + mirror_id, + polygon_chain_id, + ctf: ctf_address, + oracle: event.oracle, + condition_id: event.conditionId, + question_id: event.questionId, + outcome_slot_count: event.outcomeSlotCount, + payout_numerators: event.payoutNumerators, + tx_hash, + log_index, + block_number, + }) +} + +fn validate_payouts(outcome_slot_count: U256, payout_numerators: &[U256]) -> Result<()> { + let count: usize = outcome_slot_count + .try_into() + .map_err(|_| anyhow!("outcomeSlotCount too large for local validation"))?; + if count == 0 { + return Err(anyhow!("condition resolution outcomeSlotCount cannot be zero")); + } + if count > MAX_OUTCOME_SLOT_COUNT { + return Err(anyhow!( + "condition resolution outcomeSlotCount exceeds maximum {}", + MAX_OUTCOME_SLOT_COUNT + )); + } + if count != payout_numerators.len() { + return Err(anyhow!( + "condition resolution payout length mismatch: outcomeSlotCount={}, payoutNumerators={}", + count, + payout_numerators.len() + )); + } + if payout_numerators.iter().all(|payout| *payout == U256::ZERO) { + return Err(anyhow!("condition resolution payout vector cannot be all zero")); + } + + Ok(()) +} + +fn parse_address(task: &ParsedOracleTask, key: &str) -> Result
{ + task.params + .get(key) + .ok_or_else(|| anyhow!("Missing '{key}' parameter in Polymarket settlement URI"))? + .parse() + .map_err(|e| anyhow!("Invalid {key} address in Polymarket settlement URI: {e}")) +} + +fn parse_required_b256(task: &ParsedOracleTask, key: &str) -> Result { + task.params + .get(key) + .ok_or_else(|| anyhow!("Missing '{key}' parameter in Polymarket settlement URI"))? + .parse() + .map_err(|e| anyhow!("Invalid {key} bytes32 in Polymarket settlement URI: {e}")) +} + +fn parse_optional(task: &ParsedOracleTask, key: &str) -> Result> +where + T: std::str::FromStr, + T::Err: std::fmt::Display, +{ + task.params + .get(key) + .map(|value| value.parse::().map_err(|e| anyhow!("Invalid {key}: {e}"))) + .transpose() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{data_source::OracleDataSource, uri_parser::parse_oracle_uri}; + use alloy_primitives::{address, b256, Log as PrimitiveLog}; + use alloy_rpc_types::Log as RpcLog; + use std::env; + + const MIRROR_ID: u64 = 42; + const BLOCK_NUMBER: u64 = 50_000_000; + const LOG_INDEX: u64 = 17; + + fn ctf_address() -> Address { + address!("4D97DCd97eC945f40cF65F87097ACe5EA0476045") + } + + fn tx_hash() -> B256 { + b256!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + } + + fn condition_id() -> B256 { + b256!("1111111111111111111111111111111111111111111111111111111111111111") + } + + fn question_id() -> B256 { + b256!("2222222222222222222222222222222222222222222222222222222222222222") + } + + fn settlement_log() -> RpcLog { + let event = ConditionResolution { + conditionId: condition_id(), + oracle: address!("d91E80cF2E7be2e162c6513ceD06f1dD0dA35296"), + questionId: question_id(), + outcomeSlotCount: U256::from(2), + payoutNumerators: vec![U256::ZERO, U256::from(1)], + }; + let inner = ConditionResolution::encode_log(&PrimitiveLog::new_from_event_unchecked( + ctf_address(), + event, + )); + + RpcLog { + inner, + block_number: Some(BLOCK_NUMBER), + transaction_hash: Some(tx_hash()), + log_index: Some(LOG_INDEX), + ..Default::default() + } + } + + #[test] + fn test_decode_condition_resolution_log() { + let log = settlement_log(); + let observation = decode_condition_resolution_log( + &log, + MIRROR_ID, + DEFAULT_POLYGON_CHAIN_ID, + ctf_address(), + condition_id(), + ) + .unwrap(); + + assert_eq!(observation.mirror_id, MIRROR_ID); + assert_eq!(observation.polygon_chain_id, DEFAULT_POLYGON_CHAIN_ID); + assert_eq!(observation.condition_id, condition_id()); + assert_eq!(observation.question_id, question_id()); + assert_eq!(observation.outcome_slot_count, U256::from(2)); + assert_eq!(observation.payout_numerators, vec![U256::ZERO, U256::from(1)]); + assert_eq!(observation.tx_hash, tx_hash()); + assert_eq!(observation.log_index, LOG_INDEX); + assert_eq!(observation.block_number, BLOCK_NUMBER); + } + + #[test] + fn test_condition_filter_fails_closed_on_other_condition() { + let other_condition = + b256!("3333333333333333333333333333333333333333333333333333333333333333"); + let log = settlement_log(); + let err = decode_condition_resolution_log( + &log, + MIRROR_ID, + DEFAULT_POLYGON_CHAIN_ID, + ctf_address(), + other_condition, + ) + .unwrap_err(); + + assert!(err.to_string().contains("condition mismatch")); + } + + #[test] + fn test_wrapped_payload_is_nonce_block_and_resolver_payload() { + let log = settlement_log(); + let observation = decode_condition_resolution_log( + &log, + MIRROR_ID, + DEFAULT_POLYGON_CHAIN_ID, + ctf_address(), + condition_id(), + ) + .unwrap(); + + let delivery_nonce = 7; + let wrapped = observation.wrapped_payload(delivery_nonce); + assert_eq!(u128::from_be_bytes(wrapped[48..64].try_into().unwrap()), delivery_nonce); + assert_eq!(U256::from_be_slice(&wrapped[64..96]), U256::from(observation.block_number)); + + let resolver_offset = u64::from_be_bytes(wrapped[120..128].try_into().unwrap()) as usize; + let resolver_len = u64::from_be_bytes( + wrapped[32 + resolver_offset + 24..32 + resolver_offset + 32].try_into().unwrap(), + ) as usize; + assert!(resolver_len > 0); + } + + #[test] + fn test_observations_to_oracle_data_uses_sequential_delivery_nonce() { + let mut obs_a = decode_condition_resolution_log( + &settlement_log(), + MIRROR_ID, + DEFAULT_POLYGON_CHAIN_ID, + ctf_address(), + condition_id(), + ) + .unwrap(); + obs_a.log_index = 9; + + let mut obs_b = obs_a.clone(); + obs_b.log_index = 3; + obs_b.tx_hash = b256!("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + + let mut observations = vec![obs_a, obs_b]; + sort_and_dedup_observations(&mut observations); + let data = observations_to_oracle_data(7, &observations).unwrap(); + + assert_eq!(data.len(), 2); + assert_eq!(data[0].nonce, 8); + assert_eq!(u128::from_be_bytes(data[0].payload[48..64].try_into().unwrap()), 8); + assert_eq!(data[1].nonce, 9); + assert_eq!(u128::from_be_bytes(data[1].payload[48..64].try_into().unwrap()), 9); + assert_eq!(observations[0].log_index, 3); + assert_eq!(observations[1].log_index, 9); + } + + #[test] + fn test_sort_and_dedup_observations_removes_duplicate_source_event() { + let obs = decode_condition_resolution_log( + &settlement_log(), + MIRROR_ID, + DEFAULT_POLYGON_CHAIN_ID, + ctf_address(), + condition_id(), + ) + .unwrap(); + + let mut observations = vec![obs.clone(), obs]; + sort_and_dedup_observations(&mut observations); + + assert_eq!(observations.len(), 1); + } + + #[test] + fn test_invalid_payout_vector_fails() { + let err = validate_payouts(U256::from(2), &[U256::ZERO]).unwrap_err(); + assert!(err.to_string().contains("payout length mismatch")); + + let err = validate_payouts(U256::from(2), &[U256::ZERO, U256::ZERO]).unwrap_err(); + assert!(err.to_string().contains("all zero")); + + let payouts = vec![U256::from(1); MAX_OUTCOME_SLOT_COUNT + 1]; + let err = validate_payouts(U256::from(payouts.len()), &payouts).unwrap_err(); + assert!(err.to_string().contains("exceeds maximum")); + } + + #[test] + fn test_filtered_log_missing_metadata_fails_closed() { + let mut log = settlement_log(); + log.transaction_hash = None; + let err = decode_condition_resolution_log( + &log, + MIRROR_ID, + DEFAULT_POLYGON_CHAIN_ID, + ctf_address(), + condition_id(), + ) + .unwrap_err(); + + assert!(err.to_string().contains("missing transaction_hash")); + } + + #[tokio::test] + async fn test_polymarket_source_accepts_max_blocks_per_poll() { + let uri = format!( + "gravity://6/42/polymarket_settlement?ctf={}&fromBlock=50000000&condition={}&maxBlocksPerPoll=10", + ctf_address(), + condition_id() + ); + let task = parse_oracle_uri(&uri).expect("parse Polymarket URI"); + let source = + PolymarketSettlementSource::from_task(&task, "http://localhost:8545", 0, 50_000_000) + .await + .expect("create source"); + + assert_eq!(source.max_blocks_per_poll(), 10); + } + + #[tokio::test] + async fn test_polymarket_source_rejects_zero_max_blocks_per_poll() { + let uri = format!( + "gravity://6/42/polymarket_settlement?ctf={}&fromBlock=50000000&condition={}&maxBlocksPerPoll=0", + ctf_address(), + condition_id() + ); + let task = parse_oracle_uri(&uri).expect("parse Polymarket URI"); + let err = + PolymarketSettlementSource::from_task(&task, "http://localhost:8545", 0, 50_000_000) + .await + .unwrap_err(); + + assert!(err.to_string().contains("maxBlocksPerPoll")); + } + + #[tokio::test] + async fn test_polymarket_source_rejects_non_polygon_chain_id() { + let uri = format!( + "gravity://6/42/polymarket_settlement?ctf={}&fromBlock=50000000&condition={}&chainId=1", + ctf_address(), + condition_id() + ); + let task = parse_oracle_uri(&uri).expect("parse Polymarket URI"); + let err = + PolymarketSettlementSource::from_task(&task, "http://localhost:8545", 0, 50_000_000) + .await + .unwrap_err(); + + assert!(err.to_string().contains("chainId must be 137")); + } + + #[tokio::test] + async fn test_polymarket_source_rejects_excessive_scan_range() { + let uri = format!( + "gravity://6/42/polymarket_settlement?ctf={}&fromBlock=50000000&condition={}&maxBlocksPerPoll={}", + ctf_address(), + condition_id(), + MAX_BLOCKS_PER_POLL + 1 + ); + let task = parse_oracle_uri(&uri).expect("parse Polymarket URI"); + let err = + PolymarketSettlementSource::from_task(&task, "http://localhost:8545", 0, 50_000_000) + .await + .unwrap_err(); + + assert!(err.to_string().contains("maxBlocksPerPoll")); + } + + #[tokio::test] + async fn test_polymarket_source_requires_condition_filter() { + let uri = format!( + "gravity://6/42/polymarket_settlement?ctf={}&fromBlock=50000000", + ctf_address() + ); + let task = parse_oracle_uri(&uri).expect("parse Polymarket URI"); + let err = + PolymarketSettlementSource::from_task(&task, "http://localhost:8545", 0, 50_000_000) + .await + .unwrap_err(); + + assert!(err.to_string().contains("condition")); + } + + #[tokio::test] + #[ignore = "requires POLYGON_RPC_URL and a recent POLYMARKET_FROM_BLOCK"] + async fn test_live_poll_polygon_polymarket_settlements() { + let rpc_url = env::var("POLYGON_RPC_URL").expect("POLYGON_RPC_URL is required"); + let from_block: u64 = env::var("POLYMARKET_FROM_BLOCK") + .expect("POLYMARKET_FROM_BLOCK is required") + .parse() + .expect("POLYMARKET_FROM_BLOCK must be a u64"); + let ctf = env::var("POLYMARKET_CTF_ADDRESS").unwrap_or_else(|_| ctf_address().to_string()); + + let condition = + env::var("POLYMARKET_CONDITION_ID").expect("POLYMARKET_CONDITION_ID is required"); + let mut uri = format!( + "gravity://6/1/polymarket_settlement?ctf={ctf}&fromBlock={from_block}&condition={condition}" + ); + if let Ok(max_blocks) = env::var("POLYMARKET_MAX_BLOCKS_PER_POLL") { + uri.push_str("&maxBlocksPerPoll="); + uri.push_str(&max_blocks); + } + let task = parse_oracle_uri(&uri).expect("parse live Polymarket URI"); + let source = PolymarketSettlementSource::from_task(&task, &rpc_url, 0, task.from_block()) + .await + .expect("create live Polymarket source"); + + let data = source.poll().await.expect("poll finalized Polygon settlement logs"); + println!( + "live Polymarket settlement poll: {} item(s), cursor={}", + data.len(), + source.cursor() + ); + for item in &data { + println!("nonce={}, payload_len={}", item.nonce, item.payload.len()); + } + } +} diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs new file mode 100644 index 0000000000..3d677343ee --- /dev/null +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs @@ -0,0 +1,1439 @@ +//! Price feed oracle source. +//! +//! Data source for feeding deterministic price rounds through the +//! existing UnsupportedJWK consensus path. +//! +//! The explicit `provider=inline_fixture_v1` mode keeps all observations in the +//! `gravity://` URI for byte-identical tests. The production +//! `provider=binance_index_kline_v1` mode fetches a closed Binance USD-M +//! index-price candle and normalizes it into the same resolver payload shape. + +use crate::{ + data_source::{source_types, OracleData, OracleDataSource}, + uri_parser::ParsedOracleTask, +}; +use alloy_primitives::{keccak256, Bytes, B256, I256, U256}; +use alloy_sol_macro::sol; +use alloy_sol_types::SolValue; +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use reqwest::Client; +use serde_json::Value; +use std::{ + sync::atomic::{AtomicU64, Ordering}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::Mutex; +use tracing::info; +use url::Url; + +const PRICE_AGG_WEIGHTED_MEAN: u8 = 1; +const PRICE_AGG_WEIGHTED_MEDIAN: u8 = 2; +const PROVIDER_INLINE_FIXTURE: &str = "inline_fixture_v1"; +const PROVIDER_BINANCE_INDEX_KLINE: &str = "binance_index_kline_v1"; +const DEFAULT_BINANCE_INDEX_FIELD: &str = "close"; +const DEFAULT_BINANCE_GRACE_MS: u64 = 120_000; +const BINANCE_HTTP_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_BINANCE_RESPONSE_BYTES: usize = 64 * 1024; +const MAX_PRICE_OBSERVATIONS: usize = 16; +const MAX_PRICE_DECIMALS: u8 = 18; + +sol! { + struct PriceObservationSol { + bytes32 dataSourceId; + uint64 observedAt; + int256 price; + uint256 weight; + } + + struct PricePayloadSol { + uint256 feedId; + uint64 roundId; + uint64 resolvedAt; + uint8 decimals; + uint8 aggregationMode; + uint256 minSourceCount; + uint256 minTotalWeight; + uint64 maxStaleness; + PriceObservationSol[] observations; + } +} + +#[derive(Debug, Clone)] +struct PriceObservation { + data_source_id: B256, + observed_at: u64, + price: I256, + weight: U256, +} + +#[derive(Debug, Clone, Copy, Default)] +struct LastPriceRound { + nonce: u128, + block: u64, +} + +impl LastPriceRound { + fn is_initialized(self) -> bool { + self.nonce > 0 + } +} + +#[derive(Debug)] +enum PriceFeedMode { + Static { round_id: u64, block_number: u64, payload: Bytes }, + BinanceIndexKline { config: BinanceIndexKlineConfig, client: Client }, +} + +#[derive(Debug, Clone)] +struct BinanceIndexKlineConfig { + base_url: String, + endpoint_url: String, + pair: String, + interval: String, + interval_ms: u64, + continuous: bool, + bucket_start_ms: u64, + bucket_end_ms: u64, + grace_ms: u64, + round_id: u64, + resolved_at: u64, + decimals: u8, + aggregation_mode: u8, + min_source_count: u64, + min_total_weight: U256, + max_staleness: u64, + weight: U256, + data_source_id: B256, + delivery_nonce: u128, + block_number: u64, +} + +#[derive(Debug, Clone)] +struct BinanceIndexKlineRound { + endpoint_url: String, + bucket_start_ms: u64, + bucket_end_ms: u64, + round_id: u64, + resolved_at: u64, + delivery_nonce: u128, + block_number: u64, +} + +impl BinanceIndexKlineConfig { + fn round_for_delivery_nonce(&self, delivery_nonce: u128) -> Result { + if !self.continuous { + return Ok(BinanceIndexKlineRound { + endpoint_url: self.endpoint_url.clone(), + bucket_start_ms: self.bucket_start_ms, + bucket_end_ms: self.bucket_end_ms, + round_id: self.round_id, + resolved_at: self.resolved_at, + delivery_nonce: self.delivery_nonce, + block_number: self.block_number, + }); + } + + let offset = delivery_nonce + .checked_sub(1) + .ok_or_else(|| anyhow!("Binance continuous delivery nonce must start at 1"))?; + let offset_ms = u128::from(self.interval_ms) + .checked_mul(offset) + .ok_or_else(|| anyhow!("Binance continuous bucket offset overflow"))?; + let bucket_start_ms = u128::from(self.bucket_start_ms) + .checked_add(offset_ms) + .ok_or_else(|| anyhow!("Binance continuous bucket start overflow"))?; + let bucket_start_ms = u64::try_from(bucket_start_ms) + .map_err(|_| anyhow!("Binance continuous bucket start exceeds u64"))?; + let bucket_end_ms = bucket_start_ms + .checked_add(self.interval_ms) + .and_then(|value| value.checked_sub(1)) + .ok_or_else(|| anyhow!("Binance continuous bucket end overflow"))?; + let round_id = bucket_start_ms / self.interval_ms; + let endpoint_url = build_binance_index_kline_url( + &self.base_url, + &self.pair, + &self.interval, + bucket_start_ms, + bucket_end_ms, + )?; + + Ok(BinanceIndexKlineRound { + endpoint_url, + bucket_start_ms, + bucket_end_ms, + round_id, + resolved_at: bucket_end_ms, + delivery_nonce, + block_number: bucket_end_ms, + }) + } +} + +/// Price feed data source for `sourceType=3`. +#[derive(Debug)] +pub struct PriceFeedSource { + feed_id: u64, + mode: PriceFeedMode, + cursor: AtomicU64, + last_round: Mutex, +} + +impl PriceFeedSource { + /// Create a price feed source from a `gravity://3//price_feed` URI. + pub fn from_task(task: &ParsedOracleTask, latest_onchain_nonce: u128) -> Result { + Self::from_task_with_rpc(task, latest_onchain_nonce, None) + } + + /// Create a price feed source and optionally supply the validator-local + /// upstream URL from relayer config. The URL is intentionally not part of + /// the on-chain URI for secret-bearing endpoints. + pub fn from_task_with_rpc( + task: &ParsedOracleTask, + latest_onchain_nonce: u128, + rpc_url: Option<&str>, + ) -> Result { + Self::from_task_with_reconciled_cursor(task, latest_onchain_nonce, rpc_url, None) + } + + pub(crate) fn from_task_with_reconciled_cursor( + task: &ParsedOracleTask, + latest_onchain_nonce: u128, + rpc_url: Option<&str>, + confirmed_cursor: Option, + ) -> Result { + if task.source_type != source_types::PRICE_FEED { + return Err(anyhow!("PriceFeedSource requires sourceType={}", source_types::PRICE_FEED)); + } + + match task.params.get("provider").map(|p| p.as_str()) { + Some(PROVIDER_BINANCE_INDEX_KLINE) => { + return Self::from_binance_index_kline_task( + task, + latest_onchain_nonce, + rpc_url, + confirmed_cursor, + ); + } + Some(PROVIDER_INLINE_FIXTURE) => {} + Some(provider) => return Err(anyhow!("Unsupported price feed provider '{provider}'")), + None => return Err(anyhow!("Missing 'provider' parameter for price feed")), + } + + let round_id = parse_required::(task, "round")?; + let resolved_at = parse_required::(task, "resolvedAt")?; + let decimals = parse_required::(task, "decimals")?; + if decimals > MAX_PRICE_DECIMALS { + return Err(anyhow!( + "price feed decimals {} exceeds maximum {}", + decimals, + MAX_PRICE_DECIMALS + )); + } + let aggregation_mode = parse_required::(task, "aggregationMode")?; + let max_staleness = parse_optional(task, "maxStaleness")?.unwrap_or(60u64); + let block_number = parse_optional(task, "blockNumber")?.unwrap_or(resolved_at); + let mut observations = parse_observations(task)?; + + observations.sort_by(|a, b| a.data_source_id.as_slice().cmp(b.data_source_id.as_slice())); + + let source_count = observations.len() as u64; + let total_weight = observations.iter().try_fold(U256::ZERO, |acc, obs| { + acc.checked_add(obs.weight).ok_or_else(|| anyhow!("price feed total weight overflow")) + })?; + let min_source_count = parse_optional(task, "minSourceCount")?.unwrap_or(source_count); + let min_total_weight = + parse_optional::(task, "minTotalWeight")?.unwrap_or(total_weight); + validate_observations( + &observations, + resolved_at, + aggregation_mode, + min_source_count, + min_total_weight, + max_staleness, + total_weight, + )?; + + let resolver_payload = encode_price_payload( + task.source_id, + round_id, + resolved_at, + decimals, + aggregation_mode, + min_source_count, + min_total_weight, + max_staleness, + &observations, + ); + + let wrapped_payload = SolValue::abi_encode(&( + round_id as u128, + U256::from(block_number), + resolver_payload.as_slice(), + )); + + let last_round = if latest_onchain_nonce > 0 { + LastPriceRound { nonce: latest_onchain_nonce, block: block_number } + } else { + LastPriceRound::default() + }; + + info!( + target: "price_feed_source", + feed_id = task.source_id, + round_id, + source_count, + total_weight = %total_weight, + latest_onchain_nonce, + "Created PriceFeedSource" + ); + + Ok(Self { + feed_id: task.source_id, + mode: PriceFeedMode::Static { + round_id, + block_number, + payload: Bytes::from(wrapped_payload), + }, + cursor: AtomicU64::new(block_number), + last_round: Mutex::new(last_round), + }) + } + + fn from_binance_index_kline_task( + task: &ParsedOracleTask, + latest_onchain_nonce: u128, + rpc_url: Option<&str>, + confirmed_cursor: Option, + ) -> Result { + let pair = task + .params + .get("pair") + .ok_or_else(|| anyhow!("Missing 'pair' parameter for Binance index kline price feed"))? + .to_string(); + validate_binance_pair(&pair)?; + let interval = task.params.get("interval").cloned().unwrap_or_else(|| "1m".to_string()); + let interval_ms = binance_interval_ms(&interval)?; + let continuous = parse_optional(task, "continuous")?.unwrap_or(false); + let bucket_start_ms = parse_required::(task, "bucketStartMs")?; + if bucket_start_ms % interval_ms != 0 { + return Err(anyhow!( + "Binance index kline bucketStartMs {} is not aligned to interval {}", + bucket_start_ms, + interval + )); + } + let bucket_end_ms = bucket_start_ms + .checked_add(interval_ms) + .and_then(|value| value.checked_sub(1)) + .ok_or_else(|| anyhow!("Binance index kline bucket end overflow"))?; + for derived in ["round", "resolvedAt", "blockNumber"] { + if task.params.contains_key(derived) { + return Err(anyhow!( + "Binance index kline parameter '{derived}' is derived from the exact bucket" + )); + } + } + let grace_ms = parse_optional(task, "graceMs")?.unwrap_or(DEFAULT_BINANCE_GRACE_MS); + let round_id = bucket_start_ms / interval_ms; + let resolved_at = bucket_end_ms; + let decimals = parse_required::(task, "decimals")?; + if decimals > MAX_PRICE_DECIMALS { + return Err(anyhow!( + "price feed decimals {} exceeds maximum {}", + decimals, + MAX_PRICE_DECIMALS + )); + } + let aggregation_mode = + parse_optional(task, "aggregationMode")?.unwrap_or(PRICE_AGG_WEIGHTED_MEDIAN); + let field = task + .params + .get("field") + .cloned() + .unwrap_or_else(|| DEFAULT_BINANCE_INDEX_FIELD.to_string()); + if field != DEFAULT_BINANCE_INDEX_FIELD { + return Err(anyhow!( + "Binance index kline adapter only supports field={}", + DEFAULT_BINANCE_INDEX_FIELD + )); + } + let weight = parse_optional(task, "weight")?.unwrap_or(U256::from(1)); + let min_source_count = parse_optional(task, "minSourceCount")?.unwrap_or(1u64); + let min_total_weight = parse_optional(task, "minTotalWeight")?.unwrap_or(weight); + let max_staleness = + parse_optional(task, "maxStaleness")?.unwrap_or(interval_ms.saturating_mul(3)); + let block_number = bucket_end_ms; + let source_label = + task.params.get("dataSourceLabel").cloned().unwrap_or_else(|| { + format!("binance:usdm:indexPriceKlines:{pair}:{interval}:{field}") + }); + let data_source_id = match task.params.get("dataSourceId") { + Some(explicit) => source_id_from_label(explicit)?, + None => source_id_from_label(&source_label)?, + }; + if task.params.contains_key("baseUrl") { + return Err(anyhow!( + "Binance baseUrl must be validator-local relayer config, not an on-chain URI parameter" + )); + } + let base_url = binance_base_url(rpc_url)?; + let endpoint_url = build_binance_index_kline_url( + &base_url, + &pair, + &interval, + bucket_start_ms, + bucket_end_ms, + )?; + + validate_observations( + &[PriceObservation { + data_source_id, + observed_at: bucket_end_ms, + price: I256::ONE, + weight, + }], + resolved_at, + aggregation_mode, + min_source_count, + min_total_weight, + max_staleness, + weight, + )?; + + let client = Client::builder() + .no_proxy() + .use_rustls_tls() + .connect_timeout(Duration::from_secs(5)) + .timeout(BINANCE_HTTP_TIMEOUT) + .build() + .context("failed to build Binance index kline HTTP client")?; + let mut config = BinanceIndexKlineConfig { + base_url, + endpoint_url, + pair, + interval, + interval_ms, + continuous, + bucket_start_ms, + bucket_end_ms, + grace_ms, + round_id, + resolved_at, + decimals, + aggregation_mode, + min_source_count, + min_total_weight, + max_staleness, + weight, + data_source_id, + delivery_nonce: 0, + block_number, + }; + + let previous_block = if latest_onchain_nonce == 0 { + None + } else if continuous { + let expected = config.round_for_delivery_nonce(latest_onchain_nonce)?.block_number; + if let Some(confirmed) = confirmed_cursor { + if confirmed != expected { + return Err(anyhow!( + "Binance continuous task history mismatch: nonce {} implies block {}, confirmed cursor is {}; use a new feedId for a new bucket origin", + latest_onchain_nonce, + expected, + confirmed + )); + } + } + Some(expected) + } else { + Some(confirmed_cursor.unwrap_or(block_number)) + }; + + if !continuous { + if let Some(confirmed) = confirmed_cursor { + if latest_onchain_nonce > 0 && block_number < confirmed { + return Err(anyhow!( + "Binance fixed task bucket is older than confirmed history: bucket block {}, confirmed cursor {}; use a newer bucket or a new feedId", + block_number, + confirmed + )); + } + } + } + let already_delivered = + !continuous && latest_onchain_nonce > 0 && confirmed_cursor == Some(block_number); + config.delivery_nonce = if already_delivered { + latest_onchain_nonce + } else { + latest_onchain_nonce + .checked_add(1) + .ok_or_else(|| anyhow!("Binance index kline delivery nonce overflow"))? + }; + let last_round = previous_block + .map(|block| LastPriceRound { nonce: latest_onchain_nonce, block }) + .unwrap_or_default(); + let initial_cursor = previous_block.unwrap_or(block_number); + + info!( + target: "price_feed_source", + feed_id = task.source_id, + provider = PROVIDER_BINANCE_INDEX_KLINE, + pair = config.pair.as_str(), + interval = config.interval.as_str(), + continuous, + round_id, + bucket_start_ms, + latest_onchain_nonce, + delivery_nonce = config.delivery_nonce, + "Created Binance index kline PriceFeedSource" + ); + + Ok(Self { + feed_id: task.source_id, + mode: PriceFeedMode::BinanceIndexKline { config, client }, + cursor: AtomicU64::new(initial_cursor), + last_round: Mutex::new(last_round), + }) + } + + /// Last round nonce returned or reconciled from NativeOracle. + pub async fn last_nonce(&self) -> Option { + let state = *self.last_round.lock().await; + state.is_initialized().then_some(state.nonce) + } + + /// Block number associated with the last returned or reconciled round. + pub async fn last_nonce_block(&self) -> Option { + let state = *self.last_round.lock().await; + state.is_initialized().then_some(state.block) + } + + /// Advance local state to match an already-recorded on-chain round. + pub async fn fast_forward(&self, nonce: u128, block: u64) { + *self.last_round.lock().await = LastPriceRound { nonce, block }; + self.cursor.store(block, Ordering::Relaxed); + } + + /// Current block cursor used for relayer persistence. + pub fn cursor(&self) -> u64 { + self.cursor.load(Ordering::Relaxed) + } + + /// Feed identifier used as NativeOracle sourceId. + pub fn feed_id(&self) -> u64 { + self.feed_id + } +} + +#[async_trait] +impl OracleDataSource for PriceFeedSource { + fn source_type(&self) -> u32 { + source_types::PRICE_FEED + } + + fn source_id(&self) -> U256 { + U256::from(self.feed_id) + } + + async fn poll(&self) -> Result> { + let mut state = self.last_round.lock().await; + match &self.mode { + PriceFeedMode::Static { round_id, block_number, payload } => { + if state.nonce >= *round_id as u128 { + return Ok(vec![]); + } + + state.nonce = *round_id as u128; + state.block = *block_number; + self.cursor.store(*block_number, Ordering::Relaxed); + + Ok(vec![OracleData { nonce: *round_id as u128, payload: payload.clone() }]) + } + PriceFeedMode::BinanceIndexKline { config, client } => { + let next_delivery_nonce = if config.continuous { + state + .nonce + .checked_add(1) + .ok_or_else(|| anyhow!("Binance index kline delivery nonce overflow"))? + } else { + config.delivery_nonce + }; + if state.nonce >= next_delivery_nonce { + return Ok(vec![]); + } + + let round = config.round_for_delivery_nonce(next_delivery_nonce)?; + if config.continuous && !is_binance_bucket_ready(config, &round)? { + return Ok(vec![]); + } + + let observation = + fetch_binance_index_kline_observation(client, config, &round).await?; + let total_weight = observation.weight; + validate_observations( + std::slice::from_ref(&observation), + round.resolved_at, + config.aggregation_mode, + config.min_source_count, + config.min_total_weight, + config.max_staleness, + total_weight, + )?; + + let resolver_payload = encode_price_payload( + self.feed_id, + round.round_id, + round.resolved_at, + config.decimals, + config.aggregation_mode, + config.min_source_count, + config.min_total_weight, + config.max_staleness, + &[observation], + ); + let wrapped_payload = SolValue::abi_encode(&( + round.delivery_nonce, + U256::from(round.block_number), + resolver_payload.as_slice(), + )); + + state.nonce = round.delivery_nonce; + state.block = round.block_number; + self.cursor.store(round.block_number, Ordering::Relaxed); + + Ok(vec![OracleData { + nonce: round.delivery_nonce, + payload: Bytes::from(wrapped_payload), + }]) + } + } + } +} + +async fn fetch_binance_index_kline_observation( + client: &Client, + config: &BinanceIndexKlineConfig, + round: &BinanceIndexKlineRound, +) -> Result { + ensure_binance_bucket_ready(config, round)?; + let response = client + .get(&round.endpoint_url) + .send() + .await + .context("failed to fetch Binance index price kline")? + .error_for_status() + .context("Binance index price kline endpoint returned an error")?; + let response = read_binance_response_limited(response).await?; + let response: Value = serde_json::from_slice(&response) + .context("failed to decode Binance index price kline response")?; + + binance_index_kline_observation_from_response(config, round, &response) +} + +async fn read_binance_response_limited(mut response: reqwest::Response) -> Result> { + if response.content_length().is_some_and(|len| len > MAX_BINANCE_RESPONSE_BYTES as u64) { + return Err(binance_response_too_large()); + } + + let mut body = Vec::new(); + while let Some(chunk) = + response.chunk().await.context("failed to read Binance index price kline response")? + { + append_binance_response_chunk(&mut body, &chunk)?; + } + Ok(body) +} + +fn append_binance_response_chunk(body: &mut Vec, chunk: &[u8]) -> Result<()> { + let new_len = body.len().checked_add(chunk.len()).ok_or_else(binance_response_too_large)?; + if new_len > MAX_BINANCE_RESPONSE_BYTES { + return Err(binance_response_too_large()); + } + body.extend_from_slice(chunk); + Ok(()) +} + +fn binance_response_too_large() -> anyhow::Error { + anyhow!("Binance index price kline response exceeds {} bytes", MAX_BINANCE_RESPONSE_BYTES) +} + +fn binance_index_kline_observation_from_response( + config: &BinanceIndexKlineConfig, + round: &BinanceIndexKlineRound, + response: &Value, +) -> Result { + let row = parse_binance_index_kline_row(round, response)?; + let price = parse_fixed_decimal(row.close, config.decimals)?; + Ok(PriceObservation { + data_source_id: config.data_source_id, + observed_at: row.close_time, + price, + weight: config.weight, + }) +} + +#[derive(Debug, Clone, Copy)] +struct BinanceIndexKlineRow<'a> { + close: &'a str, + close_time: u64, +} + +fn parse_binance_index_kline_row<'a>( + round: &BinanceIndexKlineRound, + response: &'a Value, +) -> Result> { + let rows = response + .as_array() + .ok_or_else(|| anyhow!("Binance index price kline response must be an array"))?; + if rows.len() != 1 { + return Err(anyhow!( + "Binance index price kline response must contain exactly one row, got {}", + rows.len() + )); + } + let row = rows[0] + .as_array() + .ok_or_else(|| anyhow!("Binance index price kline row must be an array"))?; + if row.len() < 7 { + return Err(anyhow!("Binance index price kline row has fewer than 7 fields")); + } + let open_time = row[0] + .as_u64() + .ok_or_else(|| anyhow!("Binance index price kline openTime must be a u64"))?; + let close = row[4] + .as_str() + .ok_or_else(|| anyhow!("Binance index price kline close must be a decimal string"))?; + let close_time = row[6] + .as_u64() + .ok_or_else(|| anyhow!("Binance index price kline closeTime must be a u64"))?; + if open_time != round.bucket_start_ms { + return Err(anyhow!( + "Binance index price kline openTime mismatch: expected {}, got {}", + round.bucket_start_ms, + open_time + )); + } + if close_time != round.bucket_end_ms { + return Err(anyhow!( + "Binance index price kline closeTime mismatch: expected {}, got {}", + round.bucket_end_ms, + close_time + )); + } + + Ok(BinanceIndexKlineRow { close, close_time }) +} + +fn ensure_binance_bucket_ready( + config: &BinanceIndexKlineConfig, + round: &BinanceIndexKlineRound, +) -> Result<()> { + if !is_binance_bucket_ready(config, round)? { + let ready_at = round + .bucket_end_ms + .checked_add(config.grace_ms) + .ok_or_else(|| anyhow!("Binance index kline ready time overflow"))?; + let now_ms = current_unix_millis()?; + return Err(anyhow!( + "Binance index kline bucket is not ready: readyAtMs={}, nowMs={}", + ready_at, + now_ms + )); + } + Ok(()) +} + +fn is_binance_bucket_ready( + config: &BinanceIndexKlineConfig, + round: &BinanceIndexKlineRound, +) -> Result { + let ready_at = round + .bucket_end_ms + .checked_add(config.grace_ms) + .ok_or_else(|| anyhow!("Binance index kline ready time overflow"))?; + Ok(current_unix_millis()? >= u128::from(ready_at)) +} + +fn current_unix_millis() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before UNIX_EPOCH")? + .as_millis()) +} + +fn binance_base_url(rpc_url: Option<&str>) -> Result { + let base = rpc_url.filter(|value| !value.is_empty()).ok_or_else(|| { + anyhow!("Binance price feed requires a validator-local relayer URL mapping") + })?; + validate_http_url(base, "Binance base URL")?; + Ok(base.to_string()) +} + +fn validate_http_url(value: &str, label: &str) -> Result { + let url = Url::parse(value).map_err(|e| anyhow!("invalid {label}: {e}"))?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err(anyhow!("{label} must be an http(s) URL with a host")); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(anyhow!("{label} must not contain URL userinfo")); + } + Ok(url) +} + +fn validate_binance_pair(pair: &str) -> Result<()> { + if pair.is_empty() || + pair.len() > 32 || + !pair.bytes().all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + { + return Err(anyhow!("Binance pair must contain 1-32 uppercase ASCII letters or digits")); + } + Ok(()) +} + +fn build_binance_index_kline_url( + base_url: &str, + pair: &str, + interval: &str, + bucket_start_ms: u64, + bucket_end_ms: u64, +) -> Result { + let mut url = validate_http_url(base_url, "Binance base URL")?; + url.set_path("/fapi/v1/indexPriceKlines"); + url.set_query(None); + url.set_fragment(None); + url.query_pairs_mut() + .append_pair("pair", pair) + .append_pair("interval", interval) + .append_pair("startTime", &bucket_start_ms.to_string()) + .append_pair("endTime", &bucket_end_ms.to_string()) + .append_pair("limit", "1"); + Ok(url.to_string()) +} + +fn binance_interval_ms(interval: &str) -> Result { + match interval { + "1m" => Ok(60_000), + "3m" => Ok(180_000), + "5m" => Ok(300_000), + "15m" => Ok(900_000), + "30m" => Ok(1_800_000), + "1h" => Ok(3_600_000), + "2h" => Ok(7_200_000), + "4h" => Ok(14_400_000), + "6h" => Ok(21_600_000), + "8h" => Ok(28_800_000), + "12h" => Ok(43_200_000), + "1d" => Ok(86_400_000), + "3d" => Ok(259_200_000), + "1w" => Ok(604_800_000), + _ => Err(anyhow!("Unsupported fixed Binance kline interval '{interval}'")), + } +} + +fn encode_price_payload( + feed_id: u64, + round_id: u64, + resolved_at: u64, + decimals: u8, + aggregation_mode: u8, + min_source_count: u64, + min_total_weight: U256, + max_staleness: u64, + observations: &[PriceObservation], +) -> Vec { + let encoded_observations: Vec = observations + .iter() + .map(|obs| PriceObservationSol { + dataSourceId: obs.data_source_id, + observedAt: obs.observed_at, + price: obs.price, + weight: obs.weight, + }) + .collect(); + + PricePayloadSol { + feedId: U256::from(feed_id), + roundId: round_id, + resolvedAt: resolved_at, + decimals, + aggregationMode: aggregation_mode, + minSourceCount: U256::from(min_source_count), + minTotalWeight: min_total_weight, + maxStaleness: max_staleness, + observations: encoded_observations, + } + .abi_encode() +} + +fn parse_observations(task: &ParsedOracleTask) -> Result> { + let raw = task + .params + .get("observations") + .ok_or_else(|| anyhow!("Missing 'observations' parameter in price feed URI"))?; + if raw.trim().is_empty() { + return Err(anyhow!("Price feed observations cannot be empty")); + } + let observations: Vec<_> = raw + .split(',') + .enumerate() + .map(|(idx, item)| parse_observation(idx, item)) + .collect::>()?; + if observations.len() > MAX_PRICE_OBSERVATIONS { + return Err(anyhow!( + "price feed observation count {} exceeds maximum {}", + observations.len(), + MAX_PRICE_OBSERVATIONS + )); + } + Ok(observations) +} + +fn parse_observation(index: usize, raw: &str) -> Result { + let parts: Vec<&str> = raw.split(':').collect(); + if parts.len() != 4 { + return Err(anyhow!( + "Invalid price observation at index {}: expected source:observedAt:price:weight", + index + )); + } + + Ok(PriceObservation { + data_source_id: source_id_from_label(parts[0])?, + observed_at: parse_str(parts[1], "observedAt")?, + price: parse_str(parts[2], "price")?, + weight: parse_str(parts[3], "weight")?, + }) +} + +fn source_id_from_label(label: &str) -> Result { + if label.is_empty() { + return Err(anyhow!("price observation source label cannot be empty")); + } + + if label.starts_with("0x") { + return label + .parse() + .map_err(|e| anyhow!("invalid explicit price observation source id: {e}")); + } + + Ok(keccak256(label.as_bytes())) +} + +fn parse_fixed_decimal(value: &str, decimals: u8) -> Result { + if value.starts_with('-') { + return Err(anyhow!("price cannot be negative")); + } + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + if whole.is_empty() && fraction.is_empty() { + return Err(anyhow!("empty decimal price")); + } + if !whole.chars().all(|c| c.is_ascii_digit()) { + return Err(anyhow!("invalid decimal price whole component")); + } + if !fraction.chars().all(|c| c.is_ascii_digit()) { + return Err(anyhow!("invalid decimal price fractional component")); + } + + let mut scaled = String::with_capacity(whole.len() + decimals as usize); + scaled.push_str(if whole.is_empty() { "0" } else { whole }); + let decimals = decimals as usize; + if fraction.len() >= decimals { + scaled.push_str(&fraction[..decimals]); + } else { + scaled.push_str(fraction); + scaled.extend(std::iter::repeat_n('0', decimals - fraction.len())); + } + + let scaled = scaled.trim_start_matches('0'); + let scaled = if scaled.is_empty() { "0" } else { scaled }; + scaled.parse::().map_err(|e| anyhow!("invalid scaled decimal price: {e}")) +} + +fn validate_observations( + observations: &[PriceObservation], + resolved_at: u64, + aggregation_mode: u8, + min_source_count: u64, + min_total_weight: U256, + max_staleness: u64, + total_weight: U256, +) -> Result<()> { + if observations.len() > MAX_PRICE_OBSERVATIONS { + return Err(anyhow!( + "price feed observation count {} exceeds maximum {}", + observations.len(), + MAX_PRICE_OBSERVATIONS + )); + } + match aggregation_mode { + PRICE_AGG_WEIGHTED_MEAN | PRICE_AGG_WEIGHTED_MEDIAN => {} + _ => return Err(anyhow!("invalid price feed aggregationMode {aggregation_mode}")), + } + if min_source_count == 0 { + return Err(anyhow!("price feed minSourceCount cannot be zero")); + } + if min_source_count > observations.len() as u64 { + return Err(anyhow!( + "price feed minSourceCount {} exceeds observation count {}", + min_source_count, + observations.len() + )); + } + if min_total_weight > total_weight { + return Err(anyhow!( + "price feed minTotalWeight {} exceeds total weight {}", + min_total_weight, + total_weight + )); + } + let max_int256 = U256::MAX >> 1; + if total_weight > max_int256 { + return Err(anyhow!("price feed total weight exceeds int256 max")); + } + + for (index, observation) in observations.iter().enumerate() { + if observation.data_source_id == B256::ZERO { + return Err(anyhow!("price observation {index} has zero dataSourceId")); + } + if observation.price <= I256::ZERO { + return Err(anyhow!("price observation {index} has non-positive price")); + } + if observation.weight.is_zero() { + return Err(anyhow!("price observation {index} has zero weight")); + } + if observation.weight > max_int256 { + return Err(anyhow!("price observation {index} weight exceeds int256 max")); + } + if observation.observed_at > resolved_at { + return Err(anyhow!( + "price observation {index} is from the future: observedAt={}, resolvedAt={}", + observation.observed_at, + resolved_at + )); + } + let stale = max_staleness > 0 && + match observation.observed_at.checked_add(max_staleness) { + Some(fresh_until) => fresh_until < resolved_at, + None => true, + }; + if stale { + return Err(anyhow!( + "price observation {index} is stale: observedAt={}, resolvedAt={}, maxStaleness={}", + observation.observed_at, + resolved_at, + max_staleness + )); + } + if index > 0 && observations[index - 1].data_source_id == observation.data_source_id { + return Err(anyhow!( + "duplicate price observation dataSourceId {:?}", + observation.data_source_id + )); + } + } + + Ok(()) +} + +fn parse_required(task: &ParsedOracleTask, name: &str) -> Result +where + T: std::str::FromStr, + T::Err: std::fmt::Display, +{ + let value = task.params.get(name).ok_or_else(|| anyhow!("Missing '{name}' parameter"))?; + parse_str(value, name) +} + +fn parse_optional(task: &ParsedOracleTask, name: &str) -> Result> +where + T: std::str::FromStr, + T::Err: std::fmt::Display, +{ + task.params.get(name).map(|value| parse_str(value, name)).transpose() +} + +fn parse_str(value: &str, name: &str) -> Result +where + T: std::str::FromStr, + T::Err: std::fmt::Display, +{ + value.parse().map_err(|e| anyhow!("Invalid {name}: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::uri_parser::parse_oracle_uri; + use std::{env, fs, path::Path}; + + fn price_uri() -> &'static str { + "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-a:2000:10000000000:1,source-b:2000:10200000000:2,source-c:2000:9800000000:1" + } + + fn binance_uri() -> &'static str { + "gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1710000000000&decimals=8&aggregationMode=2" + } + + fn env_or_dotenv(name: &str) -> Option { + env::var(name).ok().or_else(|| { + let dotenv = Path::new(".env"); + let content = fs::read_to_string(dotenv).ok()?; + content.lines().find_map(|line| { + let (key, value) = line.split_once('=')?; + if key.trim() != name { + return None; + } + Some(value.trim().trim_matches('"').to_string()) + }) + }) + } + + fn binance_testnet_base_url() -> String { + env_or_dotenv("BINANCE_FUTURES_BASE_URL") + .unwrap_or_else(|| "https://testnet.binancefuture.com".to_string()) + } + + #[tokio::test] + async fn test_price_feed_source_polls_once() { + let task = parse_oracle_uri(price_uri()).unwrap(); + let source = PriceFeedSource::from_task(&task, 0).unwrap(); + + let first = source.poll().await.unwrap(); + assert_eq!(first.len(), 1); + assert_eq!(first[0].nonce, 1); + assert!(!first[0].payload.is_empty()); + assert_eq!(source.last_nonce().await, Some(1)); + + let second = source.poll().await.unwrap(); + assert!(second.is_empty()); + } + + #[tokio::test] + async fn test_price_feed_source_canonicalizes_observation_order() { + let uri_a = "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-a:2000:10000000000:1,source-b:2000:10200000000:2,source-c:2000:9800000000:1"; + let uri_b = "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-c:2000:9800000000:1,source-a:2000:10000000000:1,source-b:2000:10200000000:2"; + + let task_a = parse_oracle_uri(uri_a).unwrap(); + let task_b = parse_oracle_uri(uri_b).unwrap(); + let source_a = PriceFeedSource::from_task(&task_a, 0).unwrap(); + let source_b = PriceFeedSource::from_task(&task_b, 0).unwrap(); + + let data_a = source_a.poll().await.unwrap(); + let data_b = source_b.poll().await.unwrap(); + + assert_eq!(data_a.len(), 1); + assert_eq!(data_b.len(), 1); + assert_eq!(data_a[0].payload, data_b[0].payload); + } + + #[test] + fn test_price_feed_source_accepts_explicit_data_source_id() { + let explicit = "0x00000000000000000000000000000000000000000000000000000000000000aa"; + assert_eq!(source_id_from_label(explicit).unwrap(), explicit.parse::().unwrap()); + } + + #[test] + fn test_price_feed_source_rejects_duplicate_data_source_id() { + let uri = "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-a:2000:10000000000:1,source-a:2000:10200000000:2"; + let task = parse_oracle_uri(uri).unwrap(); + let err = PriceFeedSource::from_task(&task, 0).unwrap_err(); + + assert!(err.to_string().contains("duplicate price observation dataSourceId")); + } + + #[test] + fn test_price_feed_source_rejects_invalid_aggregation_mode() { + let uri = "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=9&observations=source-a:2000:10000000000:1"; + let task = parse_oracle_uri(uri).unwrap(); + let err = PriceFeedSource::from_task(&task, 0).unwrap_err(); + + assert!(err.to_string().contains("invalid price feed aggregationMode")); + } + + #[test] + fn test_price_feed_source_rejects_stale_observation() { + let uri = "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-a:1900:10000000000:1&maxStaleness=60"; + let task = parse_oracle_uri(uri).unwrap(); + let err = PriceFeedSource::from_task(&task, 0).unwrap_err(); + + assert!(err.to_string().contains("price observation 0 is stale")); + } + + #[test] + fn test_price_feed_source_rejects_unknown_provider() { + let uri = "gravity://3/1/price_feed?provider=hype"; + let task = parse_oracle_uri(uri).unwrap(); + let err = PriceFeedSource::from_task(&task, 0).unwrap_err(); + + assert!(err.to_string().contains("Unsupported price feed provider")); + } + + #[test] + fn test_price_feed_source_requires_explicit_provider() { + let uri = "gravity://3/1/price_feed?round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-a:2000:10000000000:1"; + let task = parse_oracle_uri(uri).unwrap(); + let err = PriceFeedSource::from_task(&task, 0).unwrap_err(); + + assert!(err.to_string().contains("Missing 'provider' parameter")); + } + + #[test] + fn test_binance_interval_ms() { + assert_eq!(binance_interval_ms("1m").unwrap(), 60_000); + assert_eq!(binance_interval_ms("4h").unwrap(), 14_400_000); + assert_eq!(binance_interval_ms("1d").unwrap(), 86_400_000); + assert!(binance_interval_ms("1x").unwrap_err().to_string().contains("Unsupported")); + } + + #[test] + fn test_binance_index_kline_source_config() { + let task = parse_oracle_uri(binance_uri()).unwrap(); + let source = + PriceFeedSource::from_task_with_rpc(&task, 41, Some("https://fapi.binance.com")) + .unwrap(); + let PriceFeedMode::BinanceIndexKline { config, .. } = &source.mode else { + panic!("expected Binance index kline mode"); + }; + + assert_eq!(config.pair, "TSLAUSDT"); + assert_eq!(config.interval, "1m"); + assert_eq!(config.interval_ms, 60_000); + assert!(!config.continuous); + assert_eq!(config.bucket_start_ms, 1_710_000_000_000); + assert_eq!(config.bucket_end_ms, 1_710_000_059_999); + assert_eq!(config.round_id, 28_500_000); + assert_eq!(config.delivery_nonce, 42); + assert_eq!( + config.endpoint_url, + "https://fapi.binance.com/fapi/v1/indexPriceKlines?pair=TSLAUSDT&interval=1m&startTime=1710000000000&endTime=1710000059999&limit=1" + ); + } + + #[test] + fn test_binance_index_kline_continuous_round_mapping() { + let uri = "gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1710000000000&decimals=8&continuous=true"; + let task = parse_oracle_uri(uri).unwrap(); + let source = + PriceFeedSource::from_task_with_rpc(&task, 2, Some("https://fapi.binance.com")) + .unwrap(); + let PriceFeedMode::BinanceIndexKline { config, .. } = &source.mode else { + panic!("expected Binance index kline mode"); + }; + + assert!(config.continuous); + assert_eq!(config.delivery_nonce, 3); + assert_eq!(source.cursor(), 1_710_000_119_999); + let round = config.round_for_delivery_nonce(3).unwrap(); + assert_eq!(round.delivery_nonce, 3); + assert_eq!(round.bucket_start_ms, 1_710_000_120_000); + assert_eq!(round.bucket_end_ms, 1_710_000_179_999); + assert_eq!(round.round_id, 28_500_002); + assert_eq!(round.resolved_at, 1_710_000_179_999); + assert_eq!( + round.endpoint_url, + "https://fapi.binance.com/fapi/v1/indexPriceKlines?pair=TSLAUSDT&interval=1m&startTime=1710000120000&endTime=1710000179999&limit=1" + ); + } + + #[tokio::test] + async fn test_fixed_binance_bucket_is_idempotent_after_restart() { + let task = parse_oracle_uri(binance_uri()).unwrap(); + let source = PriceFeedSource::from_task_with_reconciled_cursor( + &task, + 41, + Some("https://fapi.binance.com"), + Some(1_710_000_059_999), + ) + .unwrap(); + let PriceFeedMode::BinanceIndexKline { config, .. } = &source.mode else { + panic!("expected Binance index kline mode"); + }; + + assert_eq!(config.delivery_nonce, 41); + assert_eq!(source.cursor(), 1_710_000_059_999); + assert!(source.poll().await.unwrap().is_empty()); + } + + #[test] + fn test_fixed_binance_new_bucket_uses_next_delivery_nonce() { + let task = parse_oracle_uri(binance_uri()).unwrap(); + let source = PriceFeedSource::from_task_with_reconciled_cursor( + &task, + 41, + Some("https://fapi.binance.com"), + Some(1_709_999_999_999), + ) + .unwrap(); + let PriceFeedMode::BinanceIndexKline { config, .. } = &source.mode else { + panic!("expected Binance index kline mode"); + }; + + assert_eq!(config.delivery_nonce, 42); + } + + #[test] + fn test_fixed_binance_rejects_bucket_older_than_confirmed_history() { + let task = parse_oracle_uri(binance_uri()).unwrap(); + let err = PriceFeedSource::from_task_with_reconciled_cursor( + &task, + 41, + Some("https://fapi.binance.com"), + Some(1_710_000_119_999), + ) + .unwrap_err(); + + assert!(err.to_string().contains("older than confirmed history")); + } + + #[test] + fn test_continuous_binance_rejects_mismatched_history() { + let uri = "gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1710000000000&decimals=8&continuous=true"; + let task = parse_oracle_uri(uri).unwrap(); + let err = PriceFeedSource::from_task_with_reconciled_cursor( + &task, + 2, + Some("https://fapi.binance.com"), + Some(1_710_000_059_999), + ) + .unwrap_err(); + + assert!(err.to_string().contains("task history mismatch")); + } + + #[test] + fn test_binance_rejects_derived_time_overrides() { + for parameter in ["round=1", "resolvedAt=1", "blockNumber=1"] { + let uri = format!("{}&{}", binance_uri(), parameter); + let task = parse_oracle_uri(&uri).unwrap(); + let err = + PriceFeedSource::from_task_with_rpc(&task, 0, Some("https://fapi.binance.com")) + .unwrap_err(); + assert!(err.to_string().contains("derived from the exact bucket")); + } + } + + #[test] + fn test_binance_index_kline_rejects_unaligned_bucket() { + let uri = "gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1710000000001&decimals=8"; + let task = parse_oracle_uri(uri).unwrap(); + let err = PriceFeedSource::from_task_with_rpc(&task, 0, None).unwrap_err(); + + assert!(err.to_string().contains("not aligned")); + } + + #[test] + fn test_binance_index_kline_observation_from_response() { + let task = parse_oracle_uri(binance_uri()).unwrap(); + let source = + PriceFeedSource::from_task_with_rpc(&task, 0, Some("https://fapi.binance.com")) + .unwrap(); + let PriceFeedMode::BinanceIndexKline { config, .. } = &source.mode else { + panic!("expected Binance index kline mode"); + }; + let response = serde_json::json!([[ + 1710000000000u64, + "400.67293", + "400.67546", + "400.67293", + "400.67545", + "0", + 1710000059999u64, + "0", + 0, + "0", + "0", + "0" + ]]); + + let round = config.round_for_delivery_nonce(config.delivery_nonce).unwrap(); + let observation = + binance_index_kline_observation_from_response(config, &round, &response).unwrap(); + + assert_eq!(observation.observed_at, 1_710_000_059_999); + assert_eq!(observation.price, "40067545000".parse::().unwrap()); + assert_eq!(observation.weight, U256::from(1)); + } + + #[test] + fn test_binance_response_chunk_limit_is_enforced_before_buffer_growth() { + let mut body = vec![0; MAX_BINANCE_RESPONSE_BYTES - 1]; + append_binance_response_chunk(&mut body, &[1]).unwrap(); + assert_eq!(body.len(), MAX_BINANCE_RESPONSE_BYTES); + + let err = append_binance_response_chunk(&mut body, &[2]).unwrap_err(); + assert!(err.to_string().contains("response exceeds")); + assert_eq!(body.len(), MAX_BINANCE_RESPONSE_BYTES); + } + + #[test] + fn test_binance_index_kline_rejects_wrong_open_time() { + let task = parse_oracle_uri(binance_uri()).unwrap(); + let source = + PriceFeedSource::from_task_with_rpc(&task, 0, Some("https://fapi.binance.com")) + .unwrap(); + let PriceFeedMode::BinanceIndexKline { config, .. } = &source.mode else { + panic!("expected Binance index kline mode"); + }; + let response = serde_json::json!([[ + 1710000060000u64, + "400.67293", + "400.67546", + "400.67293", + "400.67545", + "0", + 1710000119999u64 + ]]); + + let round = config.round_for_delivery_nonce(config.delivery_nonce).unwrap(); + let err = + binance_index_kline_observation_from_response(config, &round, &response).unwrap_err(); + + assert!(err.to_string().contains("openTime mismatch")); + } + + #[tokio::test] + #[ignore = "requires outbound access to Binance Futures testnet"] + async fn test_binance_index_kline_live_testnet_poll() { + let base_url = binance_testnet_base_url(); + let pair = env_or_dotenv("BINANCE_INDEX_PAIR").unwrap_or_else(|| "TSLAUSDT".to_string()); + let client = Client::builder().no_proxy().use_rustls_tls().build().unwrap(); + let mut time_url = Url::parse(&base_url).unwrap(); + time_url.set_path("/fapi/v1/time"); + time_url.set_query(None); + time_url.set_fragment(None); + let server_time = client + .get(time_url) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json::() + .await + .unwrap(); + let now_ms = server_time.get("serverTime").and_then(Value::as_u64).unwrap(); + let interval_ms = binance_interval_ms("1m").unwrap(); + let bucket_start_ms = now_ms.saturating_sub(5 * interval_ms) / interval_ms * interval_ms; + let uri = format!( + "gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair={pair}&interval=1m&bucketStartMs={bucket_start_ms}&decimals=8&aggregationMode=2&graceMs=0" + ); + let task = parse_oracle_uri(&uri).unwrap(); + let source = PriceFeedSource::from_task_with_rpc(&task, 0, Some(&base_url)).unwrap(); + + let first = source.poll().await.unwrap(); + assert_eq!(first.len(), 1); + assert_eq!(first[0].nonce, 1); + assert!(!first[0].payload.is_empty()); + assert_eq!(source.last_nonce().await, Some(1)); + + let second = source.poll().await.unwrap(); + assert!(second.is_empty()); + } + + #[test] + fn test_fixed_decimal_truncates_to_configured_decimals() { + assert_eq!(parse_fixed_decimal("195.389", 2).unwrap(), "19538".parse::().unwrap()); + assert_eq!(parse_fixed_decimal("195", 8).unwrap(), "19500000000".parse::().unwrap()); + } +} diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/uri_parser.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/uri_parser.rs index 3171b3e471..7d83cd9d7c 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/uri_parser.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/uri_parser.rs @@ -10,6 +10,14 @@ //! //! ### Examples //! - Blockchain events: `gravity://0/1/events?portal=0x283fC6...&fromBlock=9565280` +//! - Inline price fixture: +//! `gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8& +//! aggregationMode=1&observations=source-a:2000:10000000000:1,...` +//! - Binance index kline price feed: +//! `gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m& +//! bucketStartMs=1710000000000&decimals=8` +//! - Polymarket settlement mirror: +//! `gravity://6/42/polymarket_settlement?ctf=0x4D97...&fromBlock=50000000&condition=0x...` use alloy_primitives::Address; use anyhow::{anyhow, Result}; @@ -22,7 +30,7 @@ pub struct ParsedOracleTask { /// Original URI string pub uri: String, - /// Source type (0=BLOCKCHAIN) + /// Source type (0=BLOCKCHAIN, 3=PRICE_FEED, 6=POLYMARKET_SETTLEMENT) pub source_type: u32, /// Source identifier (chain ID, etc.) @@ -57,6 +65,16 @@ impl ParsedOracleTask { pub fn is_blockchain(&self) -> bool { self.source_type == 0 } + + /// Check if this is a price feed source + pub fn is_price_feed(&self) -> bool { + self.source_type == 3 + } + + /// Check if this is a Polymarket settlement mirror source + pub fn is_polymarket_settlement(&self) -> bool { + self.source_type == 6 + } } /// Parse a gravity:// URI into task configuration @@ -128,6 +146,34 @@ mod tests { assert!(task.portal_address().is_ok()); } + #[test] + fn test_parse_price_feed_uri() { + let uri = "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-a:2000:10000000000:1,source-b:2000:10200000000:2"; + let task = parse_oracle_uri(uri).unwrap(); + + assert_eq!(task.source_type, 3); + assert_eq!(task.source_id, 1); + assert_eq!(task.task_type, "price_feed"); + assert!(task.is_price_feed()); + assert_eq!(task.params.get("decimals").unwrap(), "8"); + } + + #[test] + fn test_parse_polymarket_settlement_uri() { + let uri = "gravity://6/42/polymarket_settlement?ctf=0x4D97DCd97eC945f40cF65F87097ACe5EA0476045&fromBlock=50000000&condition=0x1111111111111111111111111111111111111111111111111111111111111111"; + let task = parse_oracle_uri(uri).unwrap(); + + assert_eq!(task.source_type, 6); + assert_eq!(task.source_id, 42); + assert_eq!(task.task_type, "polymarket_settlement"); + assert!(task.is_polymarket_settlement()); + assert_eq!(task.from_block(), 50000000); + assert_eq!( + task.params.get("condition").unwrap(), + "0x1111111111111111111111111111111111111111111111111111111111111111" + ); + } + #[test] fn test_invalid_scheme() { let uri = "http://0/1/events"; From 697b95da359a70de5f4f27d203b7f9e29f7e634c Mon Sep 17 00:00:00 2001 From: ByteYue Date: Sat, 18 Jul 2026 18:20:41 +0800 Subject: [PATCH 02/11] fix(oracle): scope callback gas by source type --- .../execute/src/onchain_config/jwk_oracle.rs | 65 +++++++++++++++++-- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_oracle.rs b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_oracle.rs index 8a78006f40..2940b2de10 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_oracle.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_oracle.rs @@ -13,11 +13,14 @@ use alloy_sol_macro::sol; use alloy_sol_types::{SolCall, SolValue}; use gravity_api_types::on_chain_config::jwks::{JWKStruct, ProviderJWKs}; use reth_ethereum_primitives::TransactionSigned; -use reth_pipe_exec_layer_relayer::parse_oracle_uri; +use reth_pipe_exec_layer_relayer::{parse_oracle_uri, source_types}; use tracing::{debug, info, warn}; -/// Default callback gas limit for oracle updates -const CALLBACK_GAS_LIMIT: u64 = 2_000_000; +/// Existing bridge callbacks and price resolvers fit within the legacy budget. +const STANDARD_CALLBACK_GAS_LIMIT: u64 = 500_000; + +/// Polymarket payouts may contain up to 32 outcome slots. +const POLYMARKET_CALLBACK_GAS_LIMIT: u64 = 2_000_000; // ============================================================================= // Solidity Types (NativeOracle function signatures) @@ -67,6 +70,14 @@ fn parse_source_from_issuer(issuer: &[u8]) -> Option<(u32, u64)> { Some((task.source_type, task.source_id)) } +fn callback_gas_limit(source_type: u32) -> Result { + match source_type { + source_types::BLOCKCHAIN | source_types::PRICE_FEED => Ok(STANDARD_CALLBACK_GAS_LIMIT), + source_types::POLYMARKET_SETTLEMENT => Ok(POLYMARKET_CALLBACK_GAS_LIMIT), + _ => Err(format!("Unsupported oracle source type: {source_type}")), + } +} + /// Extract a canonical ABI `(uint128 nonce, uint256 blockNumber, bytes payload)` tuple. fn extract_nonce_block_and_payload(data: &[u8]) -> Option<(u128, U256, Vec)> { let decoded = match <(u128, U256, Bytes)>::abi_decode(data) { @@ -163,6 +174,7 @@ fn construct_unsupported_oracle_batch_transaction( // Parse NativeOracle coordinates from issuer let (source_type, source_id) = parse_source_from_issuer(issuer) .ok_or_else(|| format!("Failed to parse source coordinates from issuer: {:?}", issuer))?; + let callback_gas_limit = callback_gas_limit(source_type)?; info!( target: "gravity::onchain_config::jwk_oracle", source_type, @@ -206,7 +218,7 @@ fn construct_unsupported_oracle_batch_transaction( // Use the inner payload (the original resolver payload) // This is what the user put in and what gets passed to the callback payloads.push(inner_payload.into()); - gas_limits.push(U256::from(CALLBACK_GAS_LIMIT)); + gas_limits.push(U256::from(callback_gas_limit)); debug!( idx = idx, @@ -332,7 +344,32 @@ mod tests { assert_eq!(call.nonces, vec![1]); assert_eq!(call.blockNumbers, vec![U256::from(3020u64)]); assert_eq!(call.payloads, vec![Bytes::copy_from_slice(resolver_payload)]); - assert_eq!(call.callbackGasLimits, vec![U256::from(CALLBACK_GAS_LIMIT)]); + assert_eq!(call.callbackGasLimits, vec![U256::from(STANDARD_CALLBACK_GAS_LIMIT)]); + } + + #[test] + fn test_construct_unsupported_batch_preserves_legacy_blockchain_coordinates_and_gas() { + let resolver_payload = b"bridge-event-payload"; + let wrapped_payload = + SolValue::abi_encode(&(7u128, U256::from(22_000_123u64), resolver_payload.as_slice())); + let provider = ProviderJWKs { + issuer: b"gravity://0/1/events?fromBlock=22000000".to_vec(), + version: 1, + jwks: vec![JWKStruct { + type_name: "0x1::jwks::Unsupported_JWK".to_string(), + data: wrapped_payload, + }], + }; + + let tx = construct_oracle_record_transaction(provider, 0, 0).expect("construct tx"); + let call = recordBatchCall::abi_decode(tx.input()).expect("decode recordBatch call"); + + assert_eq!(call.sourceType, source_types::BLOCKCHAIN); + assert_eq!(call.sourceId, U256::from(1)); + assert_eq!(call.nonces, vec![7]); + assert_eq!(call.blockNumbers, vec![U256::from(22_000_123u64)]); + assert_eq!(call.payloads, vec![Bytes::copy_from_slice(resolver_payload)]); + assert_eq!(call.callbackGasLimits, vec![U256::from(STANDARD_CALLBACK_GAS_LIMIT)]); } #[test] @@ -357,6 +394,24 @@ mod tests { assert_eq!(call.nonces, vec![1]); assert_eq!(call.blockNumbers, vec![U256::from(89_222_209u64)]); assert_eq!(call.payloads, vec![Bytes::copy_from_slice(resolver_payload)]); + assert_eq!(call.callbackGasLimits, vec![U256::from(POLYMARKET_CALLBACK_GAS_LIMIT)]); + } + + #[test] + fn test_construct_unsupported_batch_rejects_unknown_source_type() { + let wrapped_payload = + SolValue::abi_encode(&(1u128, U256::from(1u64), b"payload".as_slice())); + let provider = ProviderJWKs { + issuer: b"gravity://99/1/custom".to_vec(), + version: 1, + jwks: vec![JWKStruct { + type_name: "0x1::jwks::Unsupported_JWK".to_string(), + data: wrapped_payload, + }], + }; + + let err = construct_oracle_record_transaction(provider, 0, 0).unwrap_err(); + assert_eq!(err, "Unsupported oracle source type: 99"); } #[tokio::test] From 619a262b14c3e105c254607fb8292fb167816b7f Mon Sep 17 00:00:00 2001 From: ByteYue Date: Tue, 21 Jul 2026 15:11:00 +0800 Subject: [PATCH 03/11] fix(oracle): reject ambiguous relayer task sources --- .../src/onchain_config/oracle_task_helpers.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_task_helpers.rs b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_task_helpers.rs index 613a6160da..9b8ed25754 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_task_helpers.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_task_helpers.rs @@ -33,6 +33,10 @@ pub const SOURCE_TYPE_POLYMARKET_SETTLEMENT: u32 = 6; pub const RELAYER_BACKED_SOURCE_TYPES: &[u32] = &[SOURCE_TYPE_BLOCKCHAIN, SOURCE_TYPE_PRICE_FEED, SOURCE_TYPE_POLYMARKET_SETTLEMENT]; +fn has_valid_task_cardinality(source_type: u32, task_count: usize) -> bool { + !RELAYER_BACKED_SOURCE_TYPES.contains(&source_type) || task_count == 1 +} + // Re-export SOURCE_TYPE_JWK from types for consistency pub use super::types::SOURCE_TYPE_JWK; @@ -86,6 +90,17 @@ mod tests { assert!(RELAYER_BACKED_SOURCE_TYPES.contains(&SOURCE_TYPE_PRICE_FEED)); assert!(RELAYER_BACKED_SOURCE_TYPES.contains(&SOURCE_TYPE_POLYMARKET_SETTLEMENT)); } + + #[test] + fn relayer_backed_sources_require_exactly_one_task() { + for source_type in RELAYER_BACKED_SOURCE_TYPES { + assert!(!has_valid_task_cardinality(*source_type, 0)); + assert!(has_valid_task_cardinality(*source_type, 1)); + assert!(!has_valid_task_cardinality(*source_type, 2)); + } + + assert!(has_valid_task_cardinality(SOURCE_TYPE_JWK, 2)); + } } // ============================================================================= @@ -244,6 +259,17 @@ where "oracle task task names length" ); + if !has_valid_task_cardinality(source_type, task_names.len()) { + warn!( + target: "oracle_task_helper", + source_type, + source_id = source_id.to_string(), + task_count = task_names.len(), + "Relayer-backed oracle source must have exactly one task; skipping source" + ); + return; + } + for task_name in task_names { self.process_single_task(source_type, source_id, task_name, nonce, block_id, results); } From d8cb206f6dd0103269a2e0498bba839631c18181 Mon Sep 17 00:00:00 2001 From: ByteYue Date: Wed, 22 Jul 2026 14:18:06 +0800 Subject: [PATCH 04/11] refactor(oracle): make Binance feeds continuous-only Remove the unused one-shot Binance bucket mode and define binance_index_kline_v1 as a continuous price feed. Reject the legacy continuous query parameter, preserve a single nonce-to-bucket mapping, and require a new feedId when the bucket origin or interval changes. --- .../relayer/ORACLE_CANONICAL_PAYLOADS.md | 16 +- .../pipe-exec-layer-ext-v2/relayer/README.md | 9 +- .../relayer/src/price_feed_source.rs | 201 +++++------------- 3 files changed, 67 insertions(+), 159 deletions(-) diff --git a/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md b/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md index e1685ad21b..219d1c938e 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md +++ b/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md @@ -71,7 +71,6 @@ gravity://3//price_feed &pair= &interval=1m &bucketStartMs= - &continuous=true &decimals=8 &aggregationMode=2 &minSourceCount=1 @@ -80,7 +79,11 @@ gravity://3//price_feed &graceMs= ``` -For delivery nonce `n` in continuous mode: +The `binance_index_kline_v1` adapter is always continuous. It rejects the +legacy `continuous` parameter so a task cannot switch between one-shot and +long-lived nonce semantics. + +For delivery nonce `n`: ```text bucketStart(n) = configuredBucketStart + (n - 1) * intervalMs @@ -91,13 +94,10 @@ resolvedAt(n) = bucketEnd(n) `bucketStartMs` is the bucket origin for nonce `1` and is immutable for the lifetime of a `feedId`. Startup reconciliation rejects a confirmed cursor that -does not match this mapping. Use a new `feedId` when introducing a new origin. -For fixed, non-continuous tasks, a confirmed cursor equal to the target bucket -means the URI was already delivered and must not be fetched again. A newer -bucket uses the next delivery nonce; a bucket older than confirmed history is -rejected. +does not match this mapping. The interval is also immutable. Use a new +`feedId` when introducing a new origin or interval. -`round`, `resolvedAt`, and `blockNumber` are derived from the exact bucket and +`round`, `resolvedAt`, and `blockNumber` are derived from the delivery bucket and cannot be overridden in a Binance task URI. The request is: diff --git a/crates/pipe-exec-layer-ext-v2/relayer/README.md b/crates/pipe-exec-layer-ext-v2/relayer/README.md index 3ba5c53121..be62f8a318 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/README.md +++ b/crates/pipe-exec-layer-ext-v2/relayer/README.md @@ -12,16 +12,21 @@ Current runtime sources: See [ORACLE_CANONICAL_PAYLOADS.md](./ORACLE_CANONICAL_PAYLOADS.md) for URI, payload, nonce, and recovery invariants. -## Binance continuous feed +## Binance index-price feed ```text -gravity://3//price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=&continuous=true&decimals=8&aggregationMode=2&minSourceCount=1&minTotalWeight=1&maxStaleness=180000&graceMs=120000 +gravity://3//price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=&decimals=8&aggregationMode=2&minSourceCount=1&minTotalWeight=1&maxStaleness=180000&graceMs=120000 ``` +`binance_index_kline_v1` is always a continuous price feed; one-shot delivery +is not supported and the legacy `continuous` parameter is rejected. `bucketStartMs` identifies the first delivery bucket. Delivery nonce `n` maps to that start plus `(n - 1) * intervalMs`. Validators request one exact closed bucket from `/fapi/v1/indexPriceKlines` and reject mismatched timestamps. +The bucket origin and interval are immutable for a `feedId`. Use a new +`feedId` when either value changes so confirmed history remains unambiguous. + The base URL comes from validator-local relayer JSON. It is not included in the URI. Public `indexPriceKlines` requests do not use `BINANCE_API_KEY` or `BINANCE_SECRET_KEY`. diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs index 3d677343ee..b46a0d1239 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs @@ -88,16 +88,11 @@ enum PriceFeedMode { #[derive(Debug, Clone)] struct BinanceIndexKlineConfig { base_url: String, - endpoint_url: String, pair: String, interval: String, interval_ms: u64, - continuous: bool, bucket_start_ms: u64, - bucket_end_ms: u64, grace_ms: u64, - round_id: u64, - resolved_at: u64, decimals: u8, aggregation_mode: u8, min_source_count: u64, @@ -105,8 +100,6 @@ struct BinanceIndexKlineConfig { max_staleness: u64, weight: U256, data_source_id: B256, - delivery_nonce: u128, - block_number: u64, } #[derive(Debug, Clone)] @@ -122,33 +115,21 @@ struct BinanceIndexKlineRound { impl BinanceIndexKlineConfig { fn round_for_delivery_nonce(&self, delivery_nonce: u128) -> Result { - if !self.continuous { - return Ok(BinanceIndexKlineRound { - endpoint_url: self.endpoint_url.clone(), - bucket_start_ms: self.bucket_start_ms, - bucket_end_ms: self.bucket_end_ms, - round_id: self.round_id, - resolved_at: self.resolved_at, - delivery_nonce: self.delivery_nonce, - block_number: self.block_number, - }); - } - let offset = delivery_nonce .checked_sub(1) - .ok_or_else(|| anyhow!("Binance continuous delivery nonce must start at 1"))?; + .ok_or_else(|| anyhow!("Binance delivery nonce must start at 1"))?; let offset_ms = u128::from(self.interval_ms) .checked_mul(offset) - .ok_or_else(|| anyhow!("Binance continuous bucket offset overflow"))?; + .ok_or_else(|| anyhow!("Binance bucket offset overflow"))?; let bucket_start_ms = u128::from(self.bucket_start_ms) .checked_add(offset_ms) - .ok_or_else(|| anyhow!("Binance continuous bucket start overflow"))?; + .ok_or_else(|| anyhow!("Binance bucket start overflow"))?; let bucket_start_ms = u64::try_from(bucket_start_ms) - .map_err(|_| anyhow!("Binance continuous bucket start exceeds u64"))?; + .map_err(|_| anyhow!("Binance bucket start exceeds u64"))?; let bucket_end_ms = bucket_start_ms .checked_add(self.interval_ms) .and_then(|value| value.checked_sub(1)) - .ok_or_else(|| anyhow!("Binance continuous bucket end overflow"))?; + .ok_or_else(|| anyhow!("Binance bucket end overflow"))?; let round_id = bucket_start_ms / self.interval_ms; let endpoint_url = build_binance_index_kline_url( &self.base_url, @@ -314,7 +295,11 @@ impl PriceFeedSource { validate_binance_pair(&pair)?; let interval = task.params.get("interval").cloned().unwrap_or_else(|| "1m".to_string()); let interval_ms = binance_interval_ms(&interval)?; - let continuous = parse_optional(task, "continuous")?.unwrap_or(false); + if task.params.contains_key("continuous") { + return Err(anyhow!( + "Binance index kline parameter 'continuous' is unsupported; price feeds are always continuous" + )); + } let bucket_start_ms = parse_required::(task, "bucketStartMs")?; if bucket_start_ms % interval_ms != 0 { return Err(anyhow!( @@ -330,7 +315,7 @@ impl PriceFeedSource { for derived in ["round", "resolvedAt", "blockNumber"] { if task.params.contains_key(derived) { return Err(anyhow!( - "Binance index kline parameter '{derived}' is derived from the exact bucket" + "Binance index kline parameter '{derived}' is derived from the delivery bucket" )); } } @@ -378,14 +363,6 @@ impl PriceFeedSource { )); } let base_url = binance_base_url(rpc_url)?; - let endpoint_url = build_binance_index_kline_url( - &base_url, - &pair, - &interval, - bucket_start_ms, - bucket_end_ms, - )?; - validate_observations( &[PriceObservation { data_source_id, @@ -408,18 +385,13 @@ impl PriceFeedSource { .timeout(BINANCE_HTTP_TIMEOUT) .build() .context("failed to build Binance index kline HTTP client")?; - let mut config = BinanceIndexKlineConfig { + let config = BinanceIndexKlineConfig { base_url, - endpoint_url, pair, interval, interval_ms, - continuous, bucket_start_ms, - bucket_end_ms, grace_ms, - round_id, - resolved_at, decimals, aggregation_mode, min_source_count, @@ -427,18 +399,16 @@ impl PriceFeedSource { max_staleness, weight, data_source_id, - delivery_nonce: 0, - block_number, }; let previous_block = if latest_onchain_nonce == 0 { None - } else if continuous { + } else { let expected = config.round_for_delivery_nonce(latest_onchain_nonce)?.block_number; if let Some(confirmed) = confirmed_cursor { if confirmed != expected { return Err(anyhow!( - "Binance continuous task history mismatch: nonce {} implies block {}, confirmed cursor is {}; use a new feedId for a new bucket origin", + "Binance task history mismatch: nonce {} implies block {}, confirmed cursor is {}; use a new feedId for a new bucket origin or interval", latest_onchain_nonce, expected, confirmed @@ -446,29 +416,6 @@ impl PriceFeedSource { } } Some(expected) - } else { - Some(confirmed_cursor.unwrap_or(block_number)) - }; - - if !continuous { - if let Some(confirmed) = confirmed_cursor { - if latest_onchain_nonce > 0 && block_number < confirmed { - return Err(anyhow!( - "Binance fixed task bucket is older than confirmed history: bucket block {}, confirmed cursor {}; use a newer bucket or a new feedId", - block_number, - confirmed - )); - } - } - } - let already_delivered = - !continuous && latest_onchain_nonce > 0 && confirmed_cursor == Some(block_number); - config.delivery_nonce = if already_delivered { - latest_onchain_nonce - } else { - latest_onchain_nonce - .checked_add(1) - .ok_or_else(|| anyhow!("Binance index kline delivery nonce overflow"))? }; let last_round = previous_block .map(|block| LastPriceRound { nonce: latest_onchain_nonce, block }) @@ -481,11 +428,9 @@ impl PriceFeedSource { provider = PROVIDER_BINANCE_INDEX_KLINE, pair = config.pair.as_str(), interval = config.interval.as_str(), - continuous, round_id, bucket_start_ms, latest_onchain_nonce, - delivery_nonce = config.delivery_nonce, "Created Binance index kline PriceFeedSource" ); @@ -551,20 +496,12 @@ impl OracleDataSource for PriceFeedSource { Ok(vec![OracleData { nonce: *round_id as u128, payload: payload.clone() }]) } PriceFeedMode::BinanceIndexKline { config, client } => { - let next_delivery_nonce = if config.continuous { - state - .nonce - .checked_add(1) - .ok_or_else(|| anyhow!("Binance index kline delivery nonce overflow"))? - } else { - config.delivery_nonce - }; - if state.nonce >= next_delivery_nonce { - return Ok(vec![]); - } - + let next_delivery_nonce = state + .nonce + .checked_add(1) + .ok_or_else(|| anyhow!("Binance index kline delivery nonce overflow"))?; let round = config.round_for_delivery_nonce(next_delivery_nonce)?; - if config.continuous && !is_binance_bucket_ready(config, &round)? { + if !is_binance_bucket_ready(config, &round)? { return Ok(vec![]); } @@ -827,7 +764,7 @@ fn binance_interval_ms(interval: &str) -> Result { "1d" => Ok(86_400_000), "3d" => Ok(259_200_000), "1w" => Ok(604_800_000), - _ => Err(anyhow!("Unsupported fixed Binance kline interval '{interval}'")), + _ => Err(anyhow!("Unsupported Binance index kline interval '{interval}'")), } } @@ -1190,7 +1127,7 @@ mod tests { fn test_binance_index_kline_source_config() { let task = parse_oracle_uri(binance_uri()).unwrap(); let source = - PriceFeedSource::from_task_with_rpc(&task, 41, Some("https://fapi.binance.com")) + PriceFeedSource::from_task_with_rpc(&task, 0, Some("https://fapi.binance.com")) .unwrap(); let PriceFeedMode::BinanceIndexKline { config, .. } = &source.mode else { panic!("expected Binance index kline mode"); @@ -1199,20 +1136,20 @@ mod tests { assert_eq!(config.pair, "TSLAUSDT"); assert_eq!(config.interval, "1m"); assert_eq!(config.interval_ms, 60_000); - assert!(!config.continuous); assert_eq!(config.bucket_start_ms, 1_710_000_000_000); - assert_eq!(config.bucket_end_ms, 1_710_000_059_999); - assert_eq!(config.round_id, 28_500_000); - assert_eq!(config.delivery_nonce, 42); + let round = config.round_for_delivery_nonce(1).unwrap(); + assert_eq!(round.delivery_nonce, 1); + assert_eq!(round.bucket_end_ms, 1_710_000_059_999); + assert_eq!(round.round_id, 28_500_000); assert_eq!( - config.endpoint_url, + round.endpoint_url, "https://fapi.binance.com/fapi/v1/indexPriceKlines?pair=TSLAUSDT&interval=1m&startTime=1710000000000&endTime=1710000059999&limit=1" ); } #[test] - fn test_binance_index_kline_continuous_round_mapping() { - let uri = "gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1710000000000&decimals=8&continuous=true"; + fn test_binance_index_kline_round_mapping() { + let uri = "gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1710000000000&decimals=8"; let task = parse_oracle_uri(uri).unwrap(); let source = PriceFeedSource::from_task_with_rpc(&task, 2, Some("https://fapi.binance.com")) @@ -1221,8 +1158,6 @@ mod tests { panic!("expected Binance index kline mode"); }; - assert!(config.continuous); - assert_eq!(config.delivery_nonce, 3); assert_eq!(source.cursor(), 1_710_000_119_999); let round = config.round_for_delivery_nonce(3).unwrap(); assert_eq!(round.delivery_nonce, 3); @@ -1236,69 +1171,31 @@ mod tests { ); } - #[tokio::test] - async fn test_fixed_binance_bucket_is_idempotent_after_restart() { - let task = parse_oracle_uri(binance_uri()).unwrap(); - let source = PriceFeedSource::from_task_with_reconciled_cursor( - &task, - 41, - Some("https://fapi.binance.com"), - Some(1_710_000_059_999), - ) - .unwrap(); - let PriceFeedMode::BinanceIndexKline { config, .. } = &source.mode else { - panic!("expected Binance index kline mode"); - }; - - assert_eq!(config.delivery_nonce, 41); - assert_eq!(source.cursor(), 1_710_000_059_999); - assert!(source.poll().await.unwrap().is_empty()); - } - - #[test] - fn test_fixed_binance_new_bucket_uses_next_delivery_nonce() { - let task = parse_oracle_uri(binance_uri()).unwrap(); - let source = PriceFeedSource::from_task_with_reconciled_cursor( - &task, - 41, - Some("https://fapi.binance.com"), - Some(1_709_999_999_999), - ) - .unwrap(); - let PriceFeedMode::BinanceIndexKline { config, .. } = &source.mode else { - panic!("expected Binance index kline mode"); - }; - - assert_eq!(config.delivery_nonce, 42); - } - #[test] - fn test_fixed_binance_rejects_bucket_older_than_confirmed_history() { + fn test_binance_rejects_mismatched_history() { let task = parse_oracle_uri(binance_uri()).unwrap(); let err = PriceFeedSource::from_task_with_reconciled_cursor( &task, - 41, + 2, Some("https://fapi.binance.com"), - Some(1_710_000_119_999), + Some(1_710_000_059_999), ) .unwrap_err(); - assert!(err.to_string().contains("older than confirmed history")); + assert!(err.to_string().contains("task history mismatch")); } #[test] - fn test_continuous_binance_rejects_mismatched_history() { - let uri = "gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1710000000000&decimals=8&continuous=true"; - let task = parse_oracle_uri(uri).unwrap(); - let err = PriceFeedSource::from_task_with_reconciled_cursor( - &task, - 2, - Some("https://fapi.binance.com"), - Some(1_710_000_059_999), - ) - .unwrap_err(); + fn test_binance_rejects_legacy_continuous_parameter() { + for value in ["true", "false"] { + let uri = format!("{}&continuous={value}", binance_uri()); + let task = parse_oracle_uri(&uri).unwrap(); + let err = + PriceFeedSource::from_task_with_rpc(&task, 0, Some("https://fapi.binance.com")) + .unwrap_err(); - assert!(err.to_string().contains("task history mismatch")); + assert!(err.to_string().contains("price feeds are always continuous")); + } } #[test] @@ -1309,7 +1206,7 @@ mod tests { let err = PriceFeedSource::from_task_with_rpc(&task, 0, Some("https://fapi.binance.com")) .unwrap_err(); - assert!(err.to_string().contains("derived from the exact bucket")); + assert!(err.to_string().contains("derived from the delivery bucket")); } } @@ -1346,7 +1243,7 @@ mod tests { "0" ]]); - let round = config.round_for_delivery_nonce(config.delivery_nonce).unwrap(); + let round = config.round_for_delivery_nonce(1).unwrap(); let observation = binance_index_kline_observation_from_response(config, &round, &response).unwrap(); @@ -1385,7 +1282,7 @@ mod tests { 1710000119999u64 ]]); - let round = config.round_for_delivery_nonce(config.delivery_nonce).unwrap(); + let round = config.round_for_delivery_nonce(1).unwrap(); let err = binance_index_kline_observation_from_response(config, &round, &response).unwrap_err(); @@ -1414,7 +1311,7 @@ mod tests { .unwrap(); let now_ms = server_time.get("serverTime").and_then(Value::as_u64).unwrap(); let interval_ms = binance_interval_ms("1m").unwrap(); - let bucket_start_ms = now_ms.saturating_sub(5 * interval_ms) / interval_ms * interval_ms; + let bucket_start_ms = now_ms.saturating_sub(2 * interval_ms) / interval_ms * interval_ms; let uri = format!( "gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair={pair}&interval=1m&bucketStartMs={bucket_start_ms}&decimals=8&aggregationMode=2&graceMs=0" ); @@ -1428,7 +1325,13 @@ mod tests { assert_eq!(source.last_nonce().await, Some(1)); let second = source.poll().await.unwrap(); - assert!(second.is_empty()); + assert_eq!(second.len(), 1); + assert_eq!(second[0].nonce, 2); + assert!(!second[0].payload.is_empty()); + assert_eq!(source.last_nonce().await, Some(2)); + + let third = source.poll().await.unwrap(); + assert!(third.is_empty()); } #[test] From c0d45c94d570c6cd8f3068c2b098f9fdcf578553 Mon Sep 17 00:00:00 2001 From: ByteYue Date: Thu, 23 Jul 2026 16:18:59 +0800 Subject: [PATCH 05/11] refactor(oracle): remove price aggregation surface --- .../execute/src/onchain_config/jwk_oracle.rs | 48 +- .../relayer/ORACLE_CANONICAL_PAYLOADS.md | 37 +- .../pipe-exec-layer-ext-v2/relayer/README.md | 4 +- .../relayer/src/data_source.rs | 2 +- .../relayer/src/oracle_manager.rs | 18 +- .../relayer/src/price_feed_source.rs | 804 ++++-------------- .../relayer/src/uri_parser.rs | 5 +- 7 files changed, 230 insertions(+), 688 deletions(-) diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_oracle.rs b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_oracle.rs index 2940b2de10..401e6cd22d 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_oracle.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_oracle.rs @@ -257,28 +257,17 @@ fn construct_unsupported_oracle_batch_transaction( mod tests { use super::*; use alloy_consensus::Transaction; + use alloy_primitives::I256; use alloy_sol_macro::sol; use alloy_sol_types::SolValue; - use reth_pipe_exec_layer_relayer::{OracleDataSource, PriceFeedSource}; sol! { - struct PriceObservationForTest { - bytes32 dataSourceId; - uint64 observedAt; - int256 price; - uint256 weight; - } - struct PricePayloadForTest { uint256 feedId; uint64 roundId; uint64 resolvedAt; uint8 decimals; - uint8 aggregationMode; - uint256 minSourceCount; - uint256 minTotalWeight; - uint64 maxStaleness; - PriceObservationForTest[] observations; + int256 price; } } @@ -328,7 +317,7 @@ mod tests { let wrapped_payload = SolValue::abi_encode(&(1u128, U256::from(3020u64), resolver_payload.as_slice())); let provider = ProviderJWKs { - issuer: b"gravity://3/1001/price_feed?provider=inline_fixture_v1&round=1".to_vec(), + issuer: b"gravity://3/1001/price_feed?provider=binance_index_kline_v1".to_vec(), version: 1, jwks: vec![JWKStruct { type_name: "0x1::jwks::Unsupported_JWK".to_string(), @@ -414,20 +403,26 @@ mod tests { assert_eq!(err, "Unsupported oracle source type: 99"); } - #[tokio::test] - async fn test_price_feed_source_payload_reaches_record_batch() { - let uri = "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-a:2000:10000000000:1,source-b:2000:10200000000:2,source-c:2000:9800000000:1"; - let task = parse_oracle_uri(uri).expect("parse price feed uri"); - let source = PriceFeedSource::from_task(&task, 0).expect("create price feed source"); - let data = source.poll().await.expect("poll price feed source"); - assert_eq!(data.len(), 1); + #[test] + fn test_price_feed_payload_reaches_record_batch() { + let uri = "gravity://3/1/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1980000&decimals=8"; + let resolver_payload = PricePayloadForTest { + feedId: U256::from(1), + roundId: 33, + resolvedAt: 2_039_999, + decimals: 8, + price: "10000000000".parse::().unwrap(), + } + .abi_encode(); + let wrapped_payload = + SolValue::abi_encode(&(1u128, U256::from(2_039_999u64), resolver_payload.as_slice())); let provider = ProviderJWKs { issuer: uri.as_bytes().to_vec(), version: 1, jwks: vec![JWKStruct { type_name: "0x1::jwks::Unsupported_JWK".to_string(), - data: data[0].payload.to_vec(), + data: wrapped_payload, }], }; @@ -437,16 +432,15 @@ mod tests { assert_eq!(call.sourceType, 3); assert_eq!(call.sourceId, U256::from(1)); assert_eq!(call.nonces, vec![1]); - assert_eq!(call.blockNumbers, vec![U256::from(2010u64)]); + assert_eq!(call.blockNumbers, vec![U256::from(2_039_999u64)]); assert_eq!(call.payloads.len(), 1); let payload = PricePayloadForTest::abi_decode(&call.payloads[0]).expect("decode resolver payload"); assert_eq!(payload.feedId, U256::from(1)); - assert_eq!(payload.roundId, 1); - assert_eq!(payload.resolvedAt, 2010); + assert_eq!(payload.roundId, 33); + assert_eq!(payload.resolvedAt, 2_039_999); assert_eq!(payload.decimals, 8); - assert_eq!(payload.aggregationMode, 1); - assert_eq!(payload.observations.len(), 3); + assert_eq!(payload.price, "10000000000".parse::().unwrap()); } } diff --git a/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md b/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md index 219d1c938e..dbcc601935 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md +++ b/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md @@ -54,9 +54,9 @@ NativeOracle.recordBatch( ) ``` -The execution layer assigns a `2,000,000` gas callback budget per record. This -budget is covered by the resolver's maximum-size observation test; failed -callbacks do not discard the raw `NativeOracle` record and can be replayed. +The execution layer assigns the standard callback budget to Binance records and +a larger budget to bounded Polymarket payout vectors. Failed callbacks do not +discard the raw `NativeOracle` record and can be replayed. Delivery nonce is sequential for each `(sourceType, sourceId)`. It is not a Binance round id and it is not a Polygon log index. @@ -72,16 +72,13 @@ gravity://3//price_feed &interval=1m &bucketStartMs= &decimals=8 - &aggregationMode=2 - &minSourceCount=1 - &minTotalWeight=1 - &maxStaleness= &graceMs= ``` -The `binance_index_kline_v1` adapter is always continuous. It rejects the -legacy `continuous` parameter so a task cannot switch between one-shot and -long-lived nonce semantics. +The `binance_index_kline_v1` adapter is always continuous. Its accepted task +parameters are exactly `provider`, `pair`, `interval`, `bucketStartMs`, +`decimals`, and `graceMs`. Legacy aggregation and fixture parameters are +rejected. For delivery nonce `n`: @@ -122,18 +119,22 @@ Canonical acceptance rules: - close price is a positive decimal string - response body is streamed into a buffer capped at 64 KiB - connection timeout is 5 seconds and total request timeout is 15 seconds -- decimals are at most 18 and observations are capped at 16 +- decimals are at most 18 -The resolver payload is ABI encoding of `PriceFeedResolver.PricePayload`. The -default Binance observation id is: +The resolver payload is the ABI encoding of one Binance close: -```text -keccak256("binance:usdm:indexPriceKlines:::close") +```solidity +struct PricePayload { + uint256 feedId; + uint64 roundId; + uint64 resolvedAt; + uint8 decimals; + int256 price; +} ``` -`provider=inline_fixture_v1` is an explicit deterministic test adapter. Missing -`provider` is rejected, so fixture payloads cannot be selected accidentally by -an incomplete production task URI. +There is no provider weight, source count, threshold, aggregation mode, or +inline fixture in the source-type-3 protocol. ## Polymarket settlement mirror diff --git a/crates/pipe-exec-layer-ext-v2/relayer/README.md b/crates/pipe-exec-layer-ext-v2/relayer/README.md index be62f8a318..55c12464fe 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/README.md +++ b/crates/pipe-exec-layer-ext-v2/relayer/README.md @@ -15,7 +15,7 @@ payload, nonce, and recovery invariants. ## Binance index-price feed ```text -gravity://3//price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=&decimals=8&aggregationMode=2&minSourceCount=1&minTotalWeight=1&maxStaleness=180000&graceMs=120000 +gravity://3//price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=&decimals=8&graceMs=120000 ``` `binance_index_kline_v1` is always a continuous price feed; one-shot delivery @@ -23,6 +23,8 @@ is not supported and the legacy `continuous` parameter is rejected. `bucketStartMs` identifies the first delivery bucket. Delivery nonce `n` maps to that start plus `(n - 1) * intervalMs`. Validators request one exact closed bucket from `/fapi/v1/indexPriceKlines` and reject mismatched timestamps. +The payload contains that bucket's close directly; multi-source weights, +thresholds, and aggregation modes are not supported. The bucket origin and interval are immutable for a `feedId`. Use a new `feedId` when either value changes so confirmed history remains unambiguous. diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/data_source.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/data_source.rs index 060490902c..82c63bdc55 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/data_source.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/data_source.rs @@ -68,7 +68,7 @@ pub mod source_types { pub enum DataSourceKind { /// Blockchain cross-chain events (sourceType=0) Blockchain(BlockchainEventSource), - /// Price feed observations (sourceType=3) + /// Binance index-price feed (sourceType=3) PriceFeed(PriceFeedSource), /// Polymarket CTF settlement observations (sourceType=6) PolymarketSettlement(PolymarketSettlementSource), diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/oracle_manager.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/oracle_manager.rs index a8ae9e5bf2..5aeebac38f 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/oracle_manager.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/oracle_manager.rs @@ -416,7 +416,7 @@ mod tests { use super::*; fn price_uri() -> &'static str { - "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-a:2000:10000000000:1,source-b:2000:10200000000:2,source-c:2000:9800000000:1" + "gravity://3/1/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1710000000000&decimals=8" } fn polymarket_uri() -> &'static str { @@ -424,24 +424,12 @@ mod tests { } #[tokio::test] - async fn test_add_and_poll_price_feed_uri() { + async fn test_add_binance_price_feed_uri() { let datadir = tempfile::tempdir().unwrap(); let manager = OracleRelayerManager::new(datadir.path().to_path_buf()); - manager.add_uri(price_uri(), "", 0, 0).await.unwrap(); + manager.add_uri(price_uri(), "http://localhost:18547", 0, 0).await.unwrap(); assert!(manager.has_uri(price_uri()).await); - - let first = manager.poll_uri(price_uri(), None, None).await.unwrap(); - assert!(first.updated); - assert_eq!(first.nonce, Some(1)); - assert_eq!(first.max_block_number, 2010); - assert_eq!(first.jwk_structs.len(), 1); - assert_eq!(first.jwk_structs[0].type_name, source_types::PRICE_FEED.to_string()); - assert!(!first.jwk_structs[0].data.is_empty()); - - let second = manager.poll_uri(price_uri(), None, None).await.unwrap(); - assert!(!second.updated); - assert_eq!(second.jwk_structs.len(), 0); } #[tokio::test] diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs index b46a0d1239..d2e365dc75 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs @@ -1,18 +1,14 @@ -//! Price feed oracle source. +//! Binance index-price feed source. //! -//! Data source for feeding deterministic price rounds through the -//! existing UnsupportedJWK consensus path. -//! -//! The explicit `provider=inline_fixture_v1` mode keeps all observations in the -//! `gravity://` URI for byte-identical tests. The production -//! `provider=binance_index_kline_v1` mode fetches a closed Binance USD-M -//! index-price candle and normalizes it into the same resolver payload shape. +//! Each delivery nonce maps to one immutable, closed Binance USD-M +//! `indexPriceKlines` bucket. The adapter verifies the exact bucket timestamps +//! and emits one close price through the existing UnsupportedJWK consensus path. use crate::{ data_source::{source_types, OracleData, OracleDataSource}, uri_parser::ParsedOracleTask, }; -use alloy_primitives::{keccak256, Bytes, B256, I256, U256}; +use alloy_primitives::{Bytes, I256, U256}; use alloy_sol_macro::sol; use alloy_sol_types::SolValue; use anyhow::{anyhow, Context, Result}; @@ -27,46 +23,24 @@ use tokio::sync::Mutex; use tracing::info; use url::Url; -const PRICE_AGG_WEIGHTED_MEAN: u8 = 1; -const PRICE_AGG_WEIGHTED_MEDIAN: u8 = 2; -const PROVIDER_INLINE_FIXTURE: &str = "inline_fixture_v1"; const PROVIDER_BINANCE_INDEX_KLINE: &str = "binance_index_kline_v1"; -const DEFAULT_BINANCE_INDEX_FIELD: &str = "close"; const DEFAULT_BINANCE_GRACE_MS: u64 = 120_000; const BINANCE_HTTP_TIMEOUT: Duration = Duration::from_secs(15); const MAX_BINANCE_RESPONSE_BYTES: usize = 64 * 1024; -const MAX_PRICE_OBSERVATIONS: usize = 16; const MAX_PRICE_DECIMALS: u8 = 18; +const BINANCE_TASK_PARAMETERS: &[&str] = + &["provider", "pair", "interval", "bucketStartMs", "decimals", "graceMs"]; sol! { - struct PriceObservationSol { - bytes32 dataSourceId; - uint64 observedAt; - int256 price; - uint256 weight; - } - struct PricePayloadSol { uint256 feedId; uint64 roundId; uint64 resolvedAt; uint8 decimals; - uint8 aggregationMode; - uint256 minSourceCount; - uint256 minTotalWeight; - uint64 maxStaleness; - PriceObservationSol[] observations; + int256 price; } } -#[derive(Debug, Clone)] -struct PriceObservation { - data_source_id: B256, - observed_at: u64, - price: I256, - weight: U256, -} - #[derive(Debug, Clone, Copy, Default)] struct LastPriceRound { nonce: u128, @@ -79,12 +53,6 @@ impl LastPriceRound { } } -#[derive(Debug)] -enum PriceFeedMode { - Static { round_id: u64, block_number: u64, payload: Bytes }, - BinanceIndexKline { config: BinanceIndexKlineConfig, client: Client }, -} - #[derive(Debug, Clone)] struct BinanceIndexKlineConfig { base_url: String, @@ -94,12 +62,6 @@ struct BinanceIndexKlineConfig { bucket_start_ms: u64, grace_ms: u64, decimals: u8, - aggregation_mode: u8, - min_source_count: u64, - min_total_weight: U256, - max_staleness: u64, - weight: U256, - data_source_id: B256, } #[derive(Debug, Clone)] @@ -155,20 +117,22 @@ impl BinanceIndexKlineConfig { #[derive(Debug)] pub struct PriceFeedSource { feed_id: u64, - mode: PriceFeedMode, + config: BinanceIndexKlineConfig, + client: Client, cursor: AtomicU64, last_round: Mutex, } impl PriceFeedSource { - /// Create a price feed source from a `gravity://3//price_feed` URI. + /// Create a source without a validator-local endpoint mapping. + /// + /// Binance sources require a URL mapping, so production callers use + /// [`Self::from_task_with_rpc`] or [`Self::from_task_with_reconciled_cursor`]. pub fn from_task(task: &ParsedOracleTask, latest_onchain_nonce: u128) -> Result { Self::from_task_with_rpc(task, latest_onchain_nonce, None) } - /// Create a price feed source and optionally supply the validator-local - /// upstream URL from relayer config. The URL is intentionally not part of - /// the on-chain URI for secret-bearing endpoints. + /// Create a source with the validator-local Binance base URL. pub fn from_task_with_rpc( task: &ParsedOracleTask, latest_onchain_nonce: u128, @@ -186,107 +150,30 @@ impl PriceFeedSource { if task.source_type != source_types::PRICE_FEED { return Err(anyhow!("PriceFeedSource requires sourceType={}", source_types::PRICE_FEED)); } + if task.task_type != "price_feed" { + return Err(anyhow!( + "PriceFeedSource requires task type 'price_feed', got '{}'", + task.task_type + )); + } - match task.params.get("provider").map(|p| p.as_str()) { - Some(PROVIDER_BINANCE_INDEX_KLINE) => { - return Self::from_binance_index_kline_task( - task, - latest_onchain_nonce, - rpc_url, - confirmed_cursor, - ); - } - Some(PROVIDER_INLINE_FIXTURE) => {} + match task.params.get("provider").map(String::as_str) { + Some(PROVIDER_BINANCE_INDEX_KLINE) => {} Some(provider) => return Err(anyhow!("Unsupported price feed provider '{provider}'")), None => return Err(anyhow!("Missing 'provider' parameter for price feed")), } - let round_id = parse_required::(task, "round")?; - let resolved_at = parse_required::(task, "resolvedAt")?; - let decimals = parse_required::(task, "decimals")?; - if decimals > MAX_PRICE_DECIMALS { + if task.params.contains_key("baseUrl") { return Err(anyhow!( - "price feed decimals {} exceeds maximum {}", - decimals, - MAX_PRICE_DECIMALS + "Binance baseUrl must be validator-local relayer config, not an on-chain URI parameter" )); } - let aggregation_mode = parse_required::(task, "aggregationMode")?; - let max_staleness = parse_optional(task, "maxStaleness")?.unwrap_or(60u64); - let block_number = parse_optional(task, "blockNumber")?.unwrap_or(resolved_at); - let mut observations = parse_observations(task)?; - - observations.sort_by(|a, b| a.data_source_id.as_slice().cmp(b.data_source_id.as_slice())); - - let source_count = observations.len() as u64; - let total_weight = observations.iter().try_fold(U256::ZERO, |acc, obs| { - acc.checked_add(obs.weight).ok_or_else(|| anyhow!("price feed total weight overflow")) - })?; - let min_source_count = parse_optional(task, "minSourceCount")?.unwrap_or(source_count); - let min_total_weight = - parse_optional::(task, "minTotalWeight")?.unwrap_or(total_weight); - validate_observations( - &observations, - resolved_at, - aggregation_mode, - min_source_count, - min_total_weight, - max_staleness, - total_weight, - )?; - - let resolver_payload = encode_price_payload( - task.source_id, - round_id, - resolved_at, - decimals, - aggregation_mode, - min_source_count, - min_total_weight, - max_staleness, - &observations, - ); - - let wrapped_payload = SolValue::abi_encode(&( - round_id as u128, - U256::from(block_number), - resolver_payload.as_slice(), - )); - - let last_round = if latest_onchain_nonce > 0 { - LastPriceRound { nonce: latest_onchain_nonce, block: block_number } - } else { - LastPriceRound::default() - }; - - info!( - target: "price_feed_source", - feed_id = task.source_id, - round_id, - source_count, - total_weight = %total_weight, - latest_onchain_nonce, - "Created PriceFeedSource" - ); - - Ok(Self { - feed_id: task.source_id, - mode: PriceFeedMode::Static { - round_id, - block_number, - payload: Bytes::from(wrapped_payload), - }, - cursor: AtomicU64::new(block_number), - last_round: Mutex::new(last_round), - }) - } + for parameter in task.params.keys() { + if !BINANCE_TASK_PARAMETERS.contains(¶meter.as_str()) { + return Err(anyhow!("Unsupported Binance index kline parameter '{parameter}'")); + } + } - fn from_binance_index_kline_task( - task: &ParsedOracleTask, - latest_onchain_nonce: u128, - rpc_url: Option<&str>, - confirmed_cursor: Option, - ) -> Result { let pair = task .params .get("pair") @@ -295,11 +182,6 @@ impl PriceFeedSource { validate_binance_pair(&pair)?; let interval = task.params.get("interval").cloned().unwrap_or_else(|| "1m".to_string()); let interval_ms = binance_interval_ms(&interval)?; - if task.params.contains_key("continuous") { - return Err(anyhow!( - "Binance index kline parameter 'continuous' is unsupported; price feeds are always continuous" - )); - } let bucket_start_ms = parse_required::(task, "bucketStartMs")?; if bucket_start_ms % interval_ms != 0 { return Err(anyhow!( @@ -308,20 +190,6 @@ impl PriceFeedSource { interval )); } - let bucket_end_ms = bucket_start_ms - .checked_add(interval_ms) - .and_then(|value| value.checked_sub(1)) - .ok_or_else(|| anyhow!("Binance index kline bucket end overflow"))?; - for derived in ["round", "resolvedAt", "blockNumber"] { - if task.params.contains_key(derived) { - return Err(anyhow!( - "Binance index kline parameter '{derived}' is derived from the delivery bucket" - )); - } - } - let grace_ms = parse_optional(task, "graceMs")?.unwrap_or(DEFAULT_BINANCE_GRACE_MS); - let round_id = bucket_start_ms / interval_ms; - let resolved_at = bucket_end_ms; let decimals = parse_required::(task, "decimals")?; if decimals > MAX_PRICE_DECIMALS { return Err(anyhow!( @@ -330,54 +198,8 @@ impl PriceFeedSource { MAX_PRICE_DECIMALS )); } - let aggregation_mode = - parse_optional(task, "aggregationMode")?.unwrap_or(PRICE_AGG_WEIGHTED_MEDIAN); - let field = task - .params - .get("field") - .cloned() - .unwrap_or_else(|| DEFAULT_BINANCE_INDEX_FIELD.to_string()); - if field != DEFAULT_BINANCE_INDEX_FIELD { - return Err(anyhow!( - "Binance index kline adapter only supports field={}", - DEFAULT_BINANCE_INDEX_FIELD - )); - } - let weight = parse_optional(task, "weight")?.unwrap_or(U256::from(1)); - let min_source_count = parse_optional(task, "minSourceCount")?.unwrap_or(1u64); - let min_total_weight = parse_optional(task, "minTotalWeight")?.unwrap_or(weight); - let max_staleness = - parse_optional(task, "maxStaleness")?.unwrap_or(interval_ms.saturating_mul(3)); - let block_number = bucket_end_ms; - let source_label = - task.params.get("dataSourceLabel").cloned().unwrap_or_else(|| { - format!("binance:usdm:indexPriceKlines:{pair}:{interval}:{field}") - }); - let data_source_id = match task.params.get("dataSourceId") { - Some(explicit) => source_id_from_label(explicit)?, - None => source_id_from_label(&source_label)?, - }; - if task.params.contains_key("baseUrl") { - return Err(anyhow!( - "Binance baseUrl must be validator-local relayer config, not an on-chain URI parameter" - )); - } + let grace_ms = parse_optional(task, "graceMs")?.unwrap_or(DEFAULT_BINANCE_GRACE_MS); let base_url = binance_base_url(rpc_url)?; - validate_observations( - &[PriceObservation { - data_source_id, - observed_at: bucket_end_ms, - price: I256::ONE, - weight, - }], - resolved_at, - aggregation_mode, - min_source_count, - min_total_weight, - max_staleness, - weight, - )?; - let client = Client::builder() .no_proxy() .use_rustls_tls() @@ -393,12 +215,6 @@ impl PriceFeedSource { bucket_start_ms, grace_ms, decimals, - aggregation_mode, - min_source_count, - min_total_weight, - max_staleness, - weight, - data_source_id, }; let previous_block = if latest_onchain_nonce == 0 { @@ -420,7 +236,8 @@ impl PriceFeedSource { let last_round = previous_block .map(|block| LastPriceRound { nonce: latest_onchain_nonce, block }) .unwrap_or_default(); - let initial_cursor = previous_block.unwrap_or(block_number); + let initial_cursor = + previous_block.unwrap_or(config.round_for_delivery_nonce(1)?.block_number); info!( target: "price_feed_source", @@ -428,7 +245,6 @@ impl PriceFeedSource { provider = PROVIDER_BINANCE_INDEX_KLINE, pair = config.pair.as_str(), interval = config.interval.as_str(), - round_id, bucket_start_ms, latest_onchain_nonce, "Created Binance index kline PriceFeedSource" @@ -436,19 +252,20 @@ impl PriceFeedSource { Ok(Self { feed_id: task.source_id, - mode: PriceFeedMode::BinanceIndexKline { config, client }, + config, + client, cursor: AtomicU64::new(initial_cursor), last_round: Mutex::new(last_round), }) } - /// Last round nonce returned or reconciled from NativeOracle. + /// Last delivery nonce returned or reconciled from `NativeOracle`. pub async fn last_nonce(&self) -> Option { let state = *self.last_round.lock().await; state.is_initialized().then_some(state.nonce) } - /// Block number associated with the last returned or reconciled round. + /// Bucket close timestamp associated with the last delivery nonce. pub async fn last_nonce_block(&self) -> Option { let state = *self.last_round.lock().await; state.is_initialized().then_some(state.block) @@ -460,12 +277,12 @@ impl PriceFeedSource { self.cursor.store(block, Ordering::Relaxed); } - /// Current block cursor used for relayer persistence. + /// Current bucket-close cursor used for relayer persistence. pub fn cursor(&self) -> u64 { self.cursor.load(Ordering::Relaxed) } - /// Feed identifier used as NativeOracle sourceId. + /// Feed identifier used as `NativeOracle` sourceId. pub fn feed_id(&self) -> u64 { self.feed_id } @@ -483,76 +300,42 @@ impl OracleDataSource for PriceFeedSource { async fn poll(&self) -> Result> { let mut state = self.last_round.lock().await; - match &self.mode { - PriceFeedMode::Static { round_id, block_number, payload } => { - if state.nonce >= *round_id as u128 { - return Ok(vec![]); - } + let next_delivery_nonce = state + .nonce + .checked_add(1) + .ok_or_else(|| anyhow!("Binance index kline delivery nonce overflow"))?; + let round = self.config.round_for_delivery_nonce(next_delivery_nonce)?; + if !is_binance_bucket_ready(&self.config, &round)? { + return Ok(vec![]); + } - state.nonce = *round_id as u128; - state.block = *block_number; - self.cursor.store(*block_number, Ordering::Relaxed); + let price = fetch_binance_index_kline_price(&self.client, &self.config, &round).await?; + let resolver_payload = encode_price_payload( + self.feed_id, + round.round_id, + round.resolved_at, + self.config.decimals, + price, + ); + let wrapped_payload = SolValue::abi_encode(&( + round.delivery_nonce, + U256::from(round.block_number), + resolver_payload.as_slice(), + )); - Ok(vec![OracleData { nonce: *round_id as u128, payload: payload.clone() }]) - } - PriceFeedMode::BinanceIndexKline { config, client } => { - let next_delivery_nonce = state - .nonce - .checked_add(1) - .ok_or_else(|| anyhow!("Binance index kline delivery nonce overflow"))?; - let round = config.round_for_delivery_nonce(next_delivery_nonce)?; - if !is_binance_bucket_ready(config, &round)? { - return Ok(vec![]); - } + state.nonce = round.delivery_nonce; + state.block = round.block_number; + self.cursor.store(round.block_number, Ordering::Relaxed); - let observation = - fetch_binance_index_kline_observation(client, config, &round).await?; - let total_weight = observation.weight; - validate_observations( - std::slice::from_ref(&observation), - round.resolved_at, - config.aggregation_mode, - config.min_source_count, - config.min_total_weight, - config.max_staleness, - total_weight, - )?; - - let resolver_payload = encode_price_payload( - self.feed_id, - round.round_id, - round.resolved_at, - config.decimals, - config.aggregation_mode, - config.min_source_count, - config.min_total_weight, - config.max_staleness, - &[observation], - ); - let wrapped_payload = SolValue::abi_encode(&( - round.delivery_nonce, - U256::from(round.block_number), - resolver_payload.as_slice(), - )); - - state.nonce = round.delivery_nonce; - state.block = round.block_number; - self.cursor.store(round.block_number, Ordering::Relaxed); - - Ok(vec![OracleData { - nonce: round.delivery_nonce, - payload: Bytes::from(wrapped_payload), - }]) - } - } + Ok(vec![OracleData { nonce: round.delivery_nonce, payload: Bytes::from(wrapped_payload) }]) } } -async fn fetch_binance_index_kline_observation( +async fn fetch_binance_index_kline_price( client: &Client, config: &BinanceIndexKlineConfig, round: &BinanceIndexKlineRound, -) -> Result { +) -> Result { ensure_binance_bucket_ready(config, round)?; let response = client .get(&round.endpoint_url) @@ -565,7 +348,7 @@ async fn fetch_binance_index_kline_observation( let response: Value = serde_json::from_slice(&response) .context("failed to decode Binance index price kline response")?; - binance_index_kline_observation_from_response(config, round, &response) + binance_index_kline_price_from_response(config, round, &response) } async fn read_binance_response_limited(mut response: reqwest::Response) -> Result> { @@ -595,31 +378,23 @@ fn binance_response_too_large() -> anyhow::Error { anyhow!("Binance index price kline response exceeds {} bytes", MAX_BINANCE_RESPONSE_BYTES) } -fn binance_index_kline_observation_from_response( +fn binance_index_kline_price_from_response( config: &BinanceIndexKlineConfig, round: &BinanceIndexKlineRound, response: &Value, -) -> Result { - let row = parse_binance_index_kline_row(round, response)?; - let price = parse_fixed_decimal(row.close, config.decimals)?; - Ok(PriceObservation { - data_source_id: config.data_source_id, - observed_at: row.close_time, - price, - weight: config.weight, - }) -} - -#[derive(Debug, Clone, Copy)] -struct BinanceIndexKlineRow<'a> { - close: &'a str, - close_time: u64, +) -> Result { + let close = parse_binance_index_kline_close(round, response)?; + let price = parse_fixed_decimal(close, config.decimals)?; + if price <= I256::ZERO { + return Err(anyhow!("Binance index price kline close must be positive")); + } + Ok(price) } -fn parse_binance_index_kline_row<'a>( +fn parse_binance_index_kline_close<'a>( round: &BinanceIndexKlineRound, response: &'a Value, -) -> Result> { +) -> Result<&'a str> { let rows = response .as_array() .ok_or_else(|| anyhow!("Binance index price kline response must be an array"))?; @@ -659,7 +434,7 @@ fn parse_binance_index_kline_row<'a>( )); } - Ok(BinanceIndexKlineRow { close, close_time }) + Ok(close) } fn ensure_binance_bucket_ready( @@ -773,90 +548,18 @@ fn encode_price_payload( round_id: u64, resolved_at: u64, decimals: u8, - aggregation_mode: u8, - min_source_count: u64, - min_total_weight: U256, - max_staleness: u64, - observations: &[PriceObservation], + price: I256, ) -> Vec { - let encoded_observations: Vec = observations - .iter() - .map(|obs| PriceObservationSol { - dataSourceId: obs.data_source_id, - observedAt: obs.observed_at, - price: obs.price, - weight: obs.weight, - }) - .collect(); - PricePayloadSol { feedId: U256::from(feed_id), roundId: round_id, resolvedAt: resolved_at, decimals, - aggregationMode: aggregation_mode, - minSourceCount: U256::from(min_source_count), - minTotalWeight: min_total_weight, - maxStaleness: max_staleness, - observations: encoded_observations, + price, } .abi_encode() } -fn parse_observations(task: &ParsedOracleTask) -> Result> { - let raw = task - .params - .get("observations") - .ok_or_else(|| anyhow!("Missing 'observations' parameter in price feed URI"))?; - if raw.trim().is_empty() { - return Err(anyhow!("Price feed observations cannot be empty")); - } - let observations: Vec<_> = raw - .split(',') - .enumerate() - .map(|(idx, item)| parse_observation(idx, item)) - .collect::>()?; - if observations.len() > MAX_PRICE_OBSERVATIONS { - return Err(anyhow!( - "price feed observation count {} exceeds maximum {}", - observations.len(), - MAX_PRICE_OBSERVATIONS - )); - } - Ok(observations) -} - -fn parse_observation(index: usize, raw: &str) -> Result { - let parts: Vec<&str> = raw.split(':').collect(); - if parts.len() != 4 { - return Err(anyhow!( - "Invalid price observation at index {}: expected source:observedAt:price:weight", - index - )); - } - - Ok(PriceObservation { - data_source_id: source_id_from_label(parts[0])?, - observed_at: parse_str(parts[1], "observedAt")?, - price: parse_str(parts[2], "price")?, - weight: parse_str(parts[3], "weight")?, - }) -} - -fn source_id_from_label(label: &str) -> Result { - if label.is_empty() { - return Err(anyhow!("price observation source label cannot be empty")); - } - - if label.starts_with("0x") { - return label - .parse() - .map_err(|e| anyhow!("invalid explicit price observation source id: {e}")); - } - - Ok(keccak256(label.as_bytes())) -} - fn parse_fixed_decimal(value: &str, decimals: u8) -> Result { if value.starts_with('-') { return Err(anyhow!("price cannot be negative")); @@ -887,92 +590,6 @@ fn parse_fixed_decimal(value: &str, decimals: u8) -> Result { scaled.parse::().map_err(|e| anyhow!("invalid scaled decimal price: {e}")) } -fn validate_observations( - observations: &[PriceObservation], - resolved_at: u64, - aggregation_mode: u8, - min_source_count: u64, - min_total_weight: U256, - max_staleness: u64, - total_weight: U256, -) -> Result<()> { - if observations.len() > MAX_PRICE_OBSERVATIONS { - return Err(anyhow!( - "price feed observation count {} exceeds maximum {}", - observations.len(), - MAX_PRICE_OBSERVATIONS - )); - } - match aggregation_mode { - PRICE_AGG_WEIGHTED_MEAN | PRICE_AGG_WEIGHTED_MEDIAN => {} - _ => return Err(anyhow!("invalid price feed aggregationMode {aggregation_mode}")), - } - if min_source_count == 0 { - return Err(anyhow!("price feed minSourceCount cannot be zero")); - } - if min_source_count > observations.len() as u64 { - return Err(anyhow!( - "price feed minSourceCount {} exceeds observation count {}", - min_source_count, - observations.len() - )); - } - if min_total_weight > total_weight { - return Err(anyhow!( - "price feed minTotalWeight {} exceeds total weight {}", - min_total_weight, - total_weight - )); - } - let max_int256 = U256::MAX >> 1; - if total_weight > max_int256 { - return Err(anyhow!("price feed total weight exceeds int256 max")); - } - - for (index, observation) in observations.iter().enumerate() { - if observation.data_source_id == B256::ZERO { - return Err(anyhow!("price observation {index} has zero dataSourceId")); - } - if observation.price <= I256::ZERO { - return Err(anyhow!("price observation {index} has non-positive price")); - } - if observation.weight.is_zero() { - return Err(anyhow!("price observation {index} has zero weight")); - } - if observation.weight > max_int256 { - return Err(anyhow!("price observation {index} weight exceeds int256 max")); - } - if observation.observed_at > resolved_at { - return Err(anyhow!( - "price observation {index} is from the future: observedAt={}, resolvedAt={}", - observation.observed_at, - resolved_at - )); - } - let stale = max_staleness > 0 && - match observation.observed_at.checked_add(max_staleness) { - Some(fresh_until) => fresh_until < resolved_at, - None => true, - }; - if stale { - return Err(anyhow!( - "price observation {index} is stale: observedAt={}, resolvedAt={}, maxStaleness={}", - observation.observed_at, - resolved_at, - max_staleness - )); - } - if index > 0 && observations[index - 1].data_source_id == observation.data_source_id { - return Err(anyhow!( - "duplicate price observation dataSourceId {:?}", - observation.data_source_id - )); - } - } - - Ok(()) -} - fn parse_required(task: &ParsedOracleTask, name: &str) -> Result where T: std::str::FromStr, @@ -1004,12 +621,8 @@ mod tests { use crate::uri_parser::parse_oracle_uri; use std::{env, fs, path::Path}; - fn price_uri() -> &'static str { - "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-a:2000:10000000000:1,source-b:2000:10200000000:2,source-c:2000:9800000000:1" - } - fn binance_uri() -> &'static str { - "gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1710000000000&decimals=8&aggregationMode=2" + "gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1710000000000&decimals=8" } fn env_or_dotenv(name: &str) -> Option { @@ -1031,96 +644,17 @@ mod tests { .unwrap_or_else(|| "https://testnet.binancefuture.com".to_string()) } - #[tokio::test] - async fn test_price_feed_source_polls_once() { - let task = parse_oracle_uri(price_uri()).unwrap(); - let source = PriceFeedSource::from_task(&task, 0).unwrap(); - - let first = source.poll().await.unwrap(); - assert_eq!(first.len(), 1); - assert_eq!(first[0].nonce, 1); - assert!(!first[0].payload.is_empty()); - assert_eq!(source.last_nonce().await, Some(1)); - - let second = source.poll().await.unwrap(); - assert!(second.is_empty()); - } - - #[tokio::test] - async fn test_price_feed_source_canonicalizes_observation_order() { - let uri_a = "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-a:2000:10000000000:1,source-b:2000:10200000000:2,source-c:2000:9800000000:1"; - let uri_b = "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-c:2000:9800000000:1,source-a:2000:10000000000:1,source-b:2000:10200000000:2"; - - let task_a = parse_oracle_uri(uri_a).unwrap(); - let task_b = parse_oracle_uri(uri_b).unwrap(); - let source_a = PriceFeedSource::from_task(&task_a, 0).unwrap(); - let source_b = PriceFeedSource::from_task(&task_b, 0).unwrap(); - - let data_a = source_a.poll().await.unwrap(); - let data_b = source_b.poll().await.unwrap(); - - assert_eq!(data_a.len(), 1); - assert_eq!(data_b.len(), 1); - assert_eq!(data_a[0].payload, data_b[0].payload); - } - - #[test] - fn test_price_feed_source_accepts_explicit_data_source_id() { - let explicit = "0x00000000000000000000000000000000000000000000000000000000000000aa"; - assert_eq!(source_id_from_label(explicit).unwrap(), explicit.parse::().unwrap()); - } - - #[test] - fn test_price_feed_source_rejects_duplicate_data_source_id() { - let uri = "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-a:2000:10000000000:1,source-a:2000:10200000000:2"; - let task = parse_oracle_uri(uri).unwrap(); - let err = PriceFeedSource::from_task(&task, 0).unwrap_err(); - - assert!(err.to_string().contains("duplicate price observation dataSourceId")); - } - #[test] - fn test_price_feed_source_rejects_invalid_aggregation_mode() { - let uri = "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=9&observations=source-a:2000:10000000000:1"; - let task = parse_oracle_uri(uri).unwrap(); - let err = PriceFeedSource::from_task(&task, 0).unwrap_err(); - - assert!(err.to_string().contains("invalid price feed aggregationMode")); - } - - #[test] - fn test_price_feed_source_rejects_stale_observation() { - let uri = "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-a:1900:10000000000:1&maxStaleness=60"; - let task = parse_oracle_uri(uri).unwrap(); - let err = PriceFeedSource::from_task(&task, 0).unwrap_err(); - - assert!(err.to_string().contains("price observation 0 is stale")); - } - - #[test] - fn test_price_feed_source_rejects_unknown_provider() { - let uri = "gravity://3/1/price_feed?provider=hype"; - let task = parse_oracle_uri(uri).unwrap(); - let err = PriceFeedSource::from_task(&task, 0).unwrap_err(); - - assert!(err.to_string().contains("Unsupported price feed provider")); - } - - #[test] - fn test_price_feed_source_requires_explicit_provider() { - let uri = "gravity://3/1/price_feed?round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-a:2000:10000000000:1"; - let task = parse_oracle_uri(uri).unwrap(); - let err = PriceFeedSource::from_task(&task, 0).unwrap_err(); - - assert!(err.to_string().contains("Missing 'provider' parameter")); - } - - #[test] - fn test_binance_interval_ms() { - assert_eq!(binance_interval_ms("1m").unwrap(), 60_000); - assert_eq!(binance_interval_ms("4h").unwrap(), 14_400_000); - assert_eq!(binance_interval_ms("1d").unwrap(), 86_400_000); - assert!(binance_interval_ms("1x").unwrap_err().to_string().contains("Unsupported")); + fn test_price_feed_requires_binance_provider() { + for uri in [ + "gravity://3/1/price_feed?pair=TSLAUSDT", + "gravity://3/1/price_feed?provider=hype", + "gravity://3/1/price_feed?provider=inline_fixture_v1", + ] { + let task = parse_oracle_uri(uri).unwrap(); + let err = PriceFeedSource::from_task(&task, 0).unwrap_err(); + assert!(err.to_string().contains("provider"), "unexpected error: {err}"); + } } #[test] @@ -1129,15 +663,12 @@ mod tests { let source = PriceFeedSource::from_task_with_rpc(&task, 0, Some("https://fapi.binance.com")) .unwrap(); - let PriceFeedMode::BinanceIndexKline { config, .. } = &source.mode else { - panic!("expected Binance index kline mode"); - }; - assert_eq!(config.pair, "TSLAUSDT"); - assert_eq!(config.interval, "1m"); - assert_eq!(config.interval_ms, 60_000); - assert_eq!(config.bucket_start_ms, 1_710_000_000_000); - let round = config.round_for_delivery_nonce(1).unwrap(); + assert_eq!(source.config.pair, "TSLAUSDT"); + assert_eq!(source.config.interval, "1m"); + assert_eq!(source.config.interval_ms, 60_000); + assert_eq!(source.config.bucket_start_ms, 1_710_000_000_000); + let round = source.config.round_for_delivery_nonce(1).unwrap(); assert_eq!(round.delivery_nonce, 1); assert_eq!(round.bucket_end_ms, 1_710_000_059_999); assert_eq!(round.round_id, 28_500_000); @@ -1149,26 +680,18 @@ mod tests { #[test] fn test_binance_index_kline_round_mapping() { - let uri = "gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1710000000000&decimals=8"; - let task = parse_oracle_uri(uri).unwrap(); + let task = parse_oracle_uri(binance_uri()).unwrap(); let source = PriceFeedSource::from_task_with_rpc(&task, 2, Some("https://fapi.binance.com")) .unwrap(); - let PriceFeedMode::BinanceIndexKline { config, .. } = &source.mode else { - panic!("expected Binance index kline mode"); - }; assert_eq!(source.cursor(), 1_710_000_119_999); - let round = config.round_for_delivery_nonce(3).unwrap(); + let round = source.config.round_for_delivery_nonce(3).unwrap(); assert_eq!(round.delivery_nonce, 3); assert_eq!(round.bucket_start_ms, 1_710_000_120_000); assert_eq!(round.bucket_end_ms, 1_710_000_179_999); assert_eq!(round.round_id, 28_500_002); assert_eq!(round.resolved_at, 1_710_000_179_999); - assert_eq!( - round.endpoint_url, - "https://fapi.binance.com/fapi/v1/indexPriceKlines?pair=TSLAUSDT&interval=1m&startTime=1710000120000&endTime=1710000179999&limit=1" - ); } #[test] @@ -1186,48 +709,59 @@ mod tests { } #[test] - fn test_binance_rejects_legacy_continuous_parameter() { - for value in ["true", "false"] { - let uri = format!("{}&continuous={value}", binance_uri()); + fn test_binance_rejects_removed_or_derived_parameters() { + for parameter in [ + "aggregationMode=2", + "weight=1", + "minSourceCount=1", + "minTotalWeight=1", + "maxStaleness=180000", + "observations=source-a:1:1:1", + "field=close", + "dataSourceLabel=binance", + "dataSourceId=0x01", + "continuous=true", + "round=1", + "resolvedAt=1", + "blockNumber=1", + ] { + let uri = format!("{}&{parameter}", binance_uri()); let task = parse_oracle_uri(&uri).unwrap(); let err = PriceFeedSource::from_task_with_rpc(&task, 0, Some("https://fapi.binance.com")) .unwrap_err(); - - assert!(err.to_string().contains("price feeds are always continuous")); + assert!( + err.to_string().contains("Unsupported Binance index kline parameter"), + "parameter {parameter}: {err}" + ); } } #[test] - fn test_binance_rejects_derived_time_overrides() { - for parameter in ["round=1", "resolvedAt=1", "blockNumber=1"] { - let uri = format!("{}&{}", binance_uri(), parameter); - let task = parse_oracle_uri(&uri).unwrap(); - let err = - PriceFeedSource::from_task_with_rpc(&task, 0, Some("https://fapi.binance.com")) - .unwrap_err(); - assert!(err.to_string().contains("derived from the delivery bucket")); - } + fn test_binance_rejects_onchain_base_url() { + let uri = format!("{}&baseUrl=https://example.com", binance_uri()); + let task = parse_oracle_uri(&uri).unwrap(); + let err = PriceFeedSource::from_task_with_rpc(&task, 0, Some("https://fapi.binance.com")) + .unwrap_err(); + assert!(err.to_string().contains("validator-local")); } #[test] - fn test_binance_index_kline_rejects_unaligned_bucket() { + fn test_binance_rejects_unaligned_bucket() { let uri = "gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1710000000001&decimals=8"; let task = parse_oracle_uri(uri).unwrap(); - let err = PriceFeedSource::from_task_with_rpc(&task, 0, None).unwrap_err(); + let err = PriceFeedSource::from_task_with_rpc(&task, 0, Some("https://fapi.binance.com")) + .unwrap_err(); assert!(err.to_string().contains("not aligned")); } #[test] - fn test_binance_index_kline_observation_from_response() { + fn test_binance_index_kline_price_from_response() { let task = parse_oracle_uri(binance_uri()).unwrap(); let source = PriceFeedSource::from_task_with_rpc(&task, 0, Some("https://fapi.binance.com")) .unwrap(); - let PriceFeedMode::BinanceIndexKline { config, .. } = &source.mode else { - panic!("expected Binance index kline mode"); - }; let response = serde_json::json!([[ 1710000000000u64, "400.67293", @@ -1243,24 +777,25 @@ mod tests { "0" ]]); - let round = config.round_for_delivery_nonce(1).unwrap(); - let observation = - binance_index_kline_observation_from_response(config, &round, &response).unwrap(); - - assert_eq!(observation.observed_at, 1_710_000_059_999); - assert_eq!(observation.price, "40067545000".parse::().unwrap()); - assert_eq!(observation.weight, U256::from(1)); + let round = source.config.round_for_delivery_nonce(1).unwrap(); + let price = + binance_index_kline_price_from_response(&source.config, &round, &response).unwrap(); + assert_eq!(price, "40067545000".parse::().unwrap()); } #[test] - fn test_binance_response_chunk_limit_is_enforced_before_buffer_growth() { - let mut body = vec![0; MAX_BINANCE_RESPONSE_BYTES - 1]; - append_binance_response_chunk(&mut body, &[1]).unwrap(); - assert_eq!(body.len(), MAX_BINANCE_RESPONSE_BYTES); + fn test_binance_index_kline_rejects_zero_price() { + let task = parse_oracle_uri(binance_uri()).unwrap(); + let source = + PriceFeedSource::from_task_with_rpc(&task, 0, Some("https://fapi.binance.com")) + .unwrap(); + let response = + serde_json::json!([[1710000000000u64, "0", "0", "0", "0", "0", 1710000059999u64]]); - let err = append_binance_response_chunk(&mut body, &[2]).unwrap_err(); - assert!(err.to_string().contains("response exceeds")); - assert_eq!(body.len(), MAX_BINANCE_RESPONSE_BYTES); + let round = source.config.round_for_delivery_nonce(1).unwrap(); + let err = + binance_index_kline_price_from_response(&source.config, &round, &response).unwrap_err(); + assert!(err.to_string().contains("must be positive")); } #[test] @@ -1269,9 +804,6 @@ mod tests { let source = PriceFeedSource::from_task_with_rpc(&task, 0, Some("https://fapi.binance.com")) .unwrap(); - let PriceFeedMode::BinanceIndexKline { config, .. } = &source.mode else { - panic!("expected Binance index kline mode"); - }; let response = serde_json::json!([[ 1710000060000u64, "400.67293", @@ -1282,13 +814,47 @@ mod tests { 1710000119999u64 ]]); - let round = config.round_for_delivery_nonce(1).unwrap(); + let round = source.config.round_for_delivery_nonce(1).unwrap(); let err = - binance_index_kline_observation_from_response(config, &round, &response).unwrap_err(); - + binance_index_kline_price_from_response(&source.config, &round, &response).unwrap_err(); assert!(err.to_string().contains("openTime mismatch")); } + #[test] + fn test_price_payload_has_single_price() { + let encoded = encode_price_payload( + 2001, + 28_500_000, + 1_710_000_059_999, + 8, + "40067545000".parse::().unwrap(), + ); + let payload = PricePayloadSol::abi_decode(&encoded).unwrap(); + + assert_eq!(payload.feedId, U256::from(2001)); + assert_eq!(payload.roundId, 28_500_000); + assert_eq!(payload.resolvedAt, 1_710_000_059_999); + assert_eq!(payload.decimals, 8); + assert_eq!(payload.price, "40067545000".parse::().unwrap()); + } + + #[test] + fn test_binance_response_chunk_limit_is_enforced_before_buffer_growth() { + let mut body = vec![0; MAX_BINANCE_RESPONSE_BYTES - 1]; + append_binance_response_chunk(&mut body, &[1]).unwrap(); + assert_eq!(body.len(), MAX_BINANCE_RESPONSE_BYTES); + + let err = append_binance_response_chunk(&mut body, &[2]).unwrap_err(); + assert!(err.to_string().contains("response exceeds")); + assert_eq!(body.len(), MAX_BINANCE_RESPONSE_BYTES); + } + + #[test] + fn test_fixed_decimal_truncates_to_configured_decimals() { + assert_eq!(parse_fixed_decimal("195.389", 2).unwrap(), "19538".parse::().unwrap()); + assert_eq!(parse_fixed_decimal("195", 8).unwrap(), "19500000000".parse::().unwrap()); + } + #[tokio::test] #[ignore = "requires outbound access to Binance Futures testnet"] async fn test_binance_index_kline_live_testnet_poll() { @@ -1313,7 +879,7 @@ mod tests { let interval_ms = binance_interval_ms("1m").unwrap(); let bucket_start_ms = now_ms.saturating_sub(2 * interval_ms) / interval_ms * interval_ms; let uri = format!( - "gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair={pair}&interval=1m&bucketStartMs={bucket_start_ms}&decimals=8&aggregationMode=2&graceMs=0" + "gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair={pair}&interval=1m&bucketStartMs={bucket_start_ms}&decimals=8&graceMs=0" ); let task = parse_oracle_uri(&uri).unwrap(); let source = PriceFeedSource::from_task_with_rpc(&task, 0, Some(&base_url)).unwrap(); @@ -1333,10 +899,4 @@ mod tests { let third = source.poll().await.unwrap(); assert!(third.is_empty()); } - - #[test] - fn test_fixed_decimal_truncates_to_configured_decimals() { - assert_eq!(parse_fixed_decimal("195.389", 2).unwrap(), "19538".parse::().unwrap()); - assert_eq!(parse_fixed_decimal("195", 8).unwrap(), "19500000000".parse::().unwrap()); - } } diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/uri_parser.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/uri_parser.rs index 7d83cd9d7c..85119e5779 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/uri_parser.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/uri_parser.rs @@ -10,9 +10,6 @@ //! //! ### Examples //! - Blockchain events: `gravity://0/1/events?portal=0x283fC6...&fromBlock=9565280` -//! - Inline price fixture: -//! `gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8& -//! aggregationMode=1&observations=source-a:2000:10000000000:1,...` //! - Binance index kline price feed: //! `gravity://3/2001/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m& //! bucketStartMs=1710000000000&decimals=8` @@ -148,7 +145,7 @@ mod tests { #[test] fn test_parse_price_feed_uri() { - let uri = "gravity://3/1/price_feed?provider=inline_fixture_v1&round=1&resolvedAt=2010&decimals=8&aggregationMode=1&observations=source-a:2000:10000000000:1,source-b:2000:10200000000:2"; + let uri = "gravity://3/1/price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1710000000000&decimals=8"; let task = parse_oracle_uri(uri).unwrap(); assert_eq!(task.source_type, 3); From 1bf7d8caf404f19ab7a41c8754db96741e4f28d0 Mon Sep 17 00:00:00 2001 From: ByteYue Date: Thu, 23 Jul 2026 16:56:40 +0800 Subject: [PATCH 06/11] fix(oracle): fail closed on incomplete state snapshots --- .../src/onchain_config/oracle_state.rs | 261 +++++++++++++++--- 1 file changed, 219 insertions(+), 42 deletions(-) diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_state.rs b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_state.rs index e03941ab52..1436c12426 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_state.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_state.rs @@ -16,7 +16,7 @@ use alloy_sol_macro::sol; use alloy_sol_types::SolCall; use gravity_api_types::on_chain_config::oracle_state::{LatestDataRecord, OracleSourceState}; use reth_rpc_eth_api::{helpers::EthCall, RpcTypes}; -use tracing::info; +use tracing::{info, warn}; // ============================================================================= // ABI Definitions @@ -88,22 +88,34 @@ where latest_nonce: u128, block_id: BlockId, ) -> Option { + self.try_fetch_latest_record(source_type, source_id, latest_nonce, block_id).flatten() + } + + /// Fetch the latest record while preserving the distinction between a failed read and a + /// successful read of an intentionally unstored record (`StorageSkipped`). + fn try_fetch_latest_record( + &self, + source_type: u32, + source_id: U256, + latest_nonce: u128, + block_id: BlockId, + ) -> Option> { if latest_nonce == 0 { - return None; + return Some(None); } let record = self.call_get_record(source_type, source_id, latest_nonce, block_id)?; // Check if record exists (recordedAt > 0) if record.recordedAt == 0 { - return None; + return Some(None); } - Some(LatestDataRecord { + Some(Some(LatestDataRecord { recorded_at: record.recordedAt, - block_number: record.blockNumber.try_into().unwrap_or(0), + block_number: record.blockNumber.try_into().ok()?, data: record.data.to_vec(), - }) + })) } /// Fetch all oracle source states for registered relayer-backed tasks. @@ -111,46 +123,58 @@ where /// Returns BCS-serialized OracleSourceStates for registered sources. pub fn fetch(&self, block_id: BlockId) -> Option { let task_client = OracleTaskClient::new(self.base_fetcher); - let mut results = Vec::new(); - - for source_type in RELAYER_BACKED_SOURCE_TYPES { - let source_ids = - task_client.fetch_registered_source_ids(*source_type, block_id).unwrap_or_default(); - - info!( - target: "oracle_state", - source_type, - source_count = source_ids.len(), - "Fetching oracle source states" - ); - - for source_id in source_ids { - let latest_nonce = task_client - .call_get_latest_nonce(*source_type, source_id, block_id) - .unwrap_or(0); - + let results = collect_source_states( + RELAYER_BACKED_SOURCE_TYPES, + |source_type| { + let source_ids = task_client.fetch_registered_source_ids(source_type, block_id); + if source_ids.is_none() { + warn!( + target: "oracle_state", + source_type, + "Failed to fetch or decode registered oracle source ids" + ); + } + source_ids + }, + |source_type, source_id| { + let latest_nonce = + task_client.call_get_latest_nonce(source_type, source_id, block_id); + if latest_nonce.is_none() { + warn!( + target: "oracle_state", + source_type, + source_id = source_id.to_string(), + "Failed to fetch or decode latest oracle nonce" + ); + } + latest_nonce + }, + |source_type, source_id, latest_nonce| { let latest_record = - self.fetch_latest_record(*source_type, source_id, latest_nonce, block_id); + self.try_fetch_latest_record(source_type, source_id, latest_nonce, block_id); + if latest_record.is_none() { + warn!( + target: "oracle_state", + source_type, + source_id = source_id.to_string(), + latest_nonce, + "Failed to fetch or decode latest oracle record" + ); + } + latest_record + }, + )?; - info!( + bcs::to_bytes(&results) + .map(Bytes::from) + .map_err(|error| { + warn!( target: "oracle_state", - source_type, - source_id = source_id.to_string(), - latest_nonce, - has_record = latest_record.is_some(), - "Fetched oracle source state" + %error, + "Failed to BCS serialize OracleSourceStates" ); - - results.push(OracleSourceState { - source_type: *source_type, - source_id: source_id.try_into().unwrap_or(0), - latest_nonce, - latest_record, - }); - } - } - - Some(bcs::to_bytes(&results).expect("Failed to BCS serialize OracleSourceStates").into()) + }) + .ok() } /// Fetch a specific source's state by source ID @@ -176,3 +200,156 @@ where } } } + +fn collect_source_states( + source_types: &[u32], + mut source_ids_for: SourceIds, + mut latest_nonce_for: LatestNonce, + mut latest_record_for: LatestRecord, +) -> Option> +where + SourceIds: FnMut(u32) -> Option>, + LatestNonce: FnMut(u32, U256) -> Option, + LatestRecord: FnMut(u32, U256, u128) -> Option>, +{ + let mut results = Vec::new(); + + for source_type in source_types { + let source_ids = source_ids_for(*source_type)?; + + info!( + target: "oracle_state", + source_type, + source_count = source_ids.len(), + "Fetching oracle source states" + ); + + for source_id in source_ids { + let latest_nonce = latest_nonce_for(*source_type, source_id)?; + let latest_record = if latest_nonce == 0 { + None + } else { + latest_record_for(*source_type, source_id, latest_nonce)? + }; + + info!( + target: "oracle_state", + source_type, + source_id = source_id.to_string(), + latest_nonce, + has_record = latest_record.is_some(), + "Fetched oracle source state" + ); + + results.push(OracleSourceState { + source_type: *source_type, + source_id: source_id.try_into().ok()?, + latest_nonce, + latest_record, + }); + } + } + + Some(results) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + #[test] + fn authoritative_empty_source_lists_are_valid() { + let states = collect_source_states( + RELAYER_BACKED_SOURCE_TYPES, + |_| Some(vec![]), + |_, _| panic!("nonce fetch must not run without sources"), + |_, _, _| panic!("record fetch must not run without sources"), + ) + .expect("empty source lists are authoritative"); + + assert!(states.is_empty()); + } + + #[test] + fn source_id_read_failure_invalidates_whole_snapshot() { + let calls = Cell::new(0); + let states = collect_source_states( + RELAYER_BACKED_SOURCE_TYPES, + |_| { + let call = calls.get(); + calls.set(call + 1); + (call != 1).then(Vec::new) + }, + |_, _| panic!("nonce fetch must not run without sources"), + |_, _, _| panic!("record fetch must not run without sources"), + ); + + assert!(states.is_none()); + assert_eq!(calls.get(), 2); + } + + #[test] + fn nonce_read_failure_invalidates_whole_snapshot() { + let source_type = RELAYER_BACKED_SOURCE_TYPES[0]; + let source_id = U256::from(42); + let states = collect_source_states( + &[source_type], + |_| Some(vec![source_id]), + |_, _| None, + |_, _, _| panic!("record fetch must not run after nonce failure"), + ); + + assert!(states.is_none()); + } + + #[test] + fn latest_record_read_failure_invalidates_nonzero_nonce_snapshot() { + let source_type = RELAYER_BACKED_SOURCE_TYPES[0]; + let source_id = U256::from(42); + let states = collect_source_states( + &[source_type], + |_| Some(vec![source_id]), + |_, _| Some(7), + |_, _, _| None, + ); + + assert!(states.is_none()); + } + + #[test] + fn storage_skipped_is_valid_for_nonzero_nonce() { + let source_type = RELAYER_BACKED_SOURCE_TYPES[0]; + let source_id = U256::from(42); + let states = collect_source_states( + &[source_type], + |_| Some(vec![source_id]), + |_, _| Some(7), + |_, _, _| Some(None), + ) + .expect("an intentionally unstored record is an authoritative state"); + + assert_eq!(states.len(), 1); + assert_eq!(states[0].latest_nonce, 7); + assert!(states[0].latest_record.is_none()); + } + + #[test] + fn zero_nonce_is_valid_without_a_record() { + let source_type = RELAYER_BACKED_SOURCE_TYPES[0]; + let source_id = U256::from(42); + let states = collect_source_states( + &[source_type], + |_| Some(vec![source_id]), + |_, _| Some(0), + |_, _, _| panic!("record fetch must not run for nonce zero"), + ) + .expect("zero nonce is a valid empty source state"); + + assert_eq!(states.len(), 1); + assert_eq!(states[0].source_type, source_type); + assert_eq!(states[0].source_id, 42); + assert_eq!(states[0].latest_nonce, 0); + assert!(states[0].latest_record.is_none()); + } +} From 20af4ae4a2125f6232d6b2c5e7cc3f40140f2501 Mon Sep 17 00:00:00 2001 From: ByteYue Date: Thu, 23 Jul 2026 17:48:03 +0800 Subject: [PATCH 07/11] chore(oracle): align API types with latest JWK consensus Pin gravity-api-types to the Aptos revision that fixes duplicate JWK observation aggregation. This keeps the relayer ABI aligned with the SDK consensus crates for the four-validator live Binance E2E. --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 350f1b9a88..3a935d0ccd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1010,7 +1010,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "api-types" version = "0.1.0" -source = "git+https://github.com/Galxe/gravity-aptos?rev=b1f68dc85781ef0d28a568d9d64604b153be9d9e#b1f68dc85781ef0d28a568d9d64604b153be9d9e" +source = "git+https://github.com/Galxe/gravity-aptos?rev=10c4553b16aead745e1701db7885a39313607b26#10c4553b16aead745e1701db7885a39313607b26" dependencies = [ "anyhow", "async-trait", @@ -2400,7 +2400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] @@ -2896,7 +2896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" dependencies = [ "data-encoding", - "syn 2.0.111", + "syn 1.0.109", ] [[package]] @@ -3077,7 +3077,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5212,7 +5212,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -12588,7 +12588,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4c2e3f3ea1..c7b4840254 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -378,7 +378,7 @@ codegen-units = 1 [workspace.dependencies] # reth -gravity-api-types = { package = "api-types", git = "https://github.com/Galxe/gravity-aptos", rev = "b1f68dc85781ef0d28a568d9d64604b153be9d9e" } +gravity-api-types = { package = "api-types", git = "https://github.com/Galxe/gravity-aptos", rev = "10c4553b16aead745e1701db7885a39313607b26" } op-reth = { path = "crates/optimism/bin" } reth = { path = "bin/reth" } reth-storage-rpc-provider = { path = "crates/storage/rpc-provider" } From e245c938baed2e466a214d255f72c65f373b4b8d Mon Sep 17 00:00:00 2001 From: ByteYue Date: Mon, 27 Jul 2026 14:48:26 +0800 Subject: [PATCH 08/11] docs(oracle): consolidate relayer runbooks --- .../relayer/ORACLE_CANONICAL_PAYLOADS.md | 37 ++++++++- .../POLYMARKET_SETTLEMENT_LIVE_TEST.md | 82 ------------------- .../pipe-exec-layer-ext-v2/relayer/README.md | 41 ++-------- 3 files changed, 41 insertions(+), 119 deletions(-) delete mode 100644 crates/pipe-exec-layer-ext-v2/relayer/POLYMARKET_SETTLEMENT_LIVE_TEST.md diff --git a/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md b/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md index dbcc601935..b6836f4f6a 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md +++ b/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md @@ -193,7 +193,7 @@ This contract between `gravity-sdk` and `gravity-reth` is required for liveness. Do not change source cursor semantics without updating the cached-resend and restart-reconciliation tests together. -## Test commands +## Verification ```bash cargo test -p reth-pipe-exec-layer-relayer @@ -202,3 +202,38 @@ cargo test -p reth-pipe-exec-layer-ext-v2 --lib jwk_oracle Tests requiring public Binance or Polygon traffic are ignored by default. The normal suite is deterministic and local. + +### Optional live Polymarket check + +After public-network access is explicitly approved, provide a Polygon RPC URL, +CTF address, exact condition id, and an exclusive cursor shortly before its +`ConditionResolution` event. Keep the scan range small enough for the provider. +Do not put the RPC URL in the `gravity://` URI or commit it. + +```bash +POLYGON_RPC_URL='' \ +POLYMARKET_CTF_ADDRESS='' \ +POLYMARKET_CONDITION_ID='' \ +POLYMARKET_FROM_BLOCK='' \ +POLYMARKET_MAX_BLOCKS_PER_POLL='100' \ +cargo test -p reth-pipe-exec-layer-relayer \ + test_live_poll_polygon_polymarket_settlements --lib -- --ignored --nocapture +``` + +A successful check proves that the endpoint reports chain id `137`, finalized +block lookup succeeds, and one matching resolution produces a canonical +payload whose source block, transaction hash, log index, and non-zero payout +vector match Polygon. + +Wrong-chain endpoints and malformed matching logs fail closed. An empty result +usually means the condition, CTF address, exclusive cursor, or finalized height +does not cover the event; the provider must support the `finalized` block tag. +If a callback fails, the raw record remains available for +`replaySettlement(mirrorId, nonce)`. + +The deterministic SDK suite covers the full consensus, execution, resolver, +market-settlement, and claim path: + +```bash +./gravity_e2e/run_test.sh polymarket_mock --force-init +``` diff --git a/crates/pipe-exec-layer-ext-v2/relayer/POLYMARKET_SETTLEMENT_LIVE_TEST.md b/crates/pipe-exec-layer-ext-v2/relayer/POLYMARKET_SETTLEMENT_LIVE_TEST.md deleted file mode 100644 index db4465c771..0000000000 --- a/crates/pipe-exec-layer-ext-v2/relayer/POLYMARKET_SETTLEMENT_LIVE_TEST.md +++ /dev/null @@ -1,82 +0,0 @@ -# Polymarket Settlement Live-Test Runbook - -The default relayer test suite is local and deterministic. This runbook is for -an explicitly approved Polygon RPC check of one known CTF condition. - -## Inputs - -Provide: - -- Polygon RPC URL -- CTF contract address -- exact condition id -- a recent block before its `ConditionResolution` event -- a small `maxBlocksPerPoll` range appropriate for the RPC provider - -Do not put the RPC URL into the `gravity://` URI or commit it to the repository. - -## URI - -```text -gravity://6//polymarket_settlement - ?ctf= - &condition= - &fromBlock= - &maxBlocksPerPoll= -``` - -The condition is mandatory. The adapter verifies `eth_chainId == 137` before -reading finalized logs. Scanning begins at `fromBlock + 1`. - -## Focused adapter test - -After outbound access is approved: - -```bash -POLYGON_RPC_URL='' \ -POLYMARKET_CTF_ADDRESS='' \ -POLYMARKET_CONDITION_ID='' \ -POLYMARKET_FROM_BLOCK='' \ -POLYMARKET_MAX_BLOCKS_PER_POLL='100' \ -cargo test -p reth-pipe-exec-layer-relayer \ - test_live_poll_polygon_polymarket_settlements --lib -- --ignored --nocapture -``` - -Expected evidence: - -- RPC chain id is accepted as `137` -- finalized block lookup succeeds -- one matching resolution produces one canonical payload -- source block, transaction hash, and log index match Polygon -- payout vector length equals `outcomeSlotCount` and is not all zero - -## Full local chain test - -Use the deterministic SDK suite for the consensus and execution path: - -```bash -./gravity_e2e/run_test.sh polymarket_mock --force-init -``` - -That suite proves: - -```text -finalized ConditionResolution fixture --> gravity-reth canonical payload --> UnsupportedJWK validator consensus --> NativeOracle sourceType=6 record --> PolymarketSettlementResolver --> Polymarket market settlement and claim -``` - -## Failure handling - -- Wrong chain id: reject the endpoint; do not override the check. -- Missing finalized tag: use a Polygon provider that implements finalized block - queries. -- Empty result: verify condition topic, CTF address, start block, and finalized - height. -- Callback failure: inspect the stored raw record and callback event, fix the - configuration, then call `replaySettlement(mirrorId, nonce)`. -- Persisted progress ahead of chain state: restart reconciliation rolls back to - `NativeOracle`'s confirmed nonce and source block. diff --git a/crates/pipe-exec-layer-ext-v2/relayer/README.md b/crates/pipe-exec-layer-ext-v2/relayer/README.md index 55c12464fe..6d8fb23ce3 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/README.md +++ b/crates/pipe-exec-layer-ext-v2/relayer/README.md @@ -3,45 +3,15 @@ This crate hosts validator-local source adapters that produce canonical bytes for Gravity's UnsupportedJWK consensus path. -Current runtime sources: +Supported runtime sources are: - `sourceType=0`: existing GravityPortal blockchain events - `sourceType=3`: Binance closed index-price klines - `sourceType=6`: finalized Polygon Polymarket CTF settlements -See [ORACLE_CANONICAL_PAYLOADS.md](./ORACLE_CANONICAL_PAYLOADS.md) for URI, -payload, nonce, and recovery invariants. - -## Binance index-price feed - -```text -gravity://3//price_feed?provider=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=&decimals=8&graceMs=120000 -``` - -`binance_index_kline_v1` is always a continuous price feed; one-shot delivery -is not supported and the legacy `continuous` parameter is rejected. -`bucketStartMs` identifies the first delivery bucket. Delivery nonce `n` maps -to that start plus `(n - 1) * intervalMs`. Validators request one exact closed -bucket from `/fapi/v1/indexPriceKlines` and reject mismatched timestamps. -The payload contains that bucket's close directly; multi-source weights, -thresholds, and aggregation modes are not supported. - -The bucket origin and interval are immutable for a `feedId`. Use a new -`feedId` when either value changes so confirmed history remains unambiguous. - -The base URL comes from validator-local relayer JSON. It is not included in the -URI. Public `indexPriceKlines` requests do not use `BINANCE_API_KEY` or -`BINANCE_SECRET_KEY`. - -## Polymarket mirror - -```text -gravity://6//polymarket_settlement?ctf=
&condition=&fromBlock=&maxBlocksPerPoll=1000 -``` - -The source checks RPC chain id `137`, reads only finalized blocks, and filters -one reviewed condition. A mirror task without `condition` is rejected. A -malformed filtered log fails closed without advancing the cursor. +The canonical [Oracle Relayer Protocol](./ORACLE_CANONICAL_PAYLOADS.md) +documents task URIs, payloads, nonce and cursor semantics, recovery behavior, +and the optional live Polymarket check. ## Local verification @@ -50,5 +20,4 @@ cargo test -p reth-pipe-exec-layer-relayer cargo test -p reth-pipe-exec-layer-ext-v2 --lib jwk_oracle ``` -Ignored live tests require explicit public network access and are not part of -the normal test gate. +Public-network tests are ignored by default. From 9b31bdcf0deed78954883620c512a2979850e075 Mon Sep 17 00:00:00 2001 From: ByteYue Date: Thu, 30 Jul 2026 16:00:58 +0800 Subject: [PATCH 09/11] refactor(oracle): persist fixed-size source progress --- Cargo.lock | 2 +- Cargo.toml | 2 +- .../execute/src/onchain_config/errors.rs | 2 +- .../execute/src/onchain_config/jwk_oracle.rs | 19 +- .../src/onchain_config/oracle_state.rs | 209 ++++++------------ .../relayer/ORACLE_CANONICAL_PAYLOADS.md | 6 +- .../relayer/src/blockchain_source.rs | 42 +++- .../relayer/src/data_source.rs | 7 +- .../relayer/src/oracle_manager.rs | 135 +++++++---- .../relayer/src/persistence.rs | 4 +- .../src/polymarket_settlement_source.rs | 21 +- .../relayer/src/price_feed_source.rs | 6 +- 12 files changed, 245 insertions(+), 210 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 347713de93..0a8e91d4af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1069,7 +1069,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "api-types" version = "0.1.0" -source = "git+https://github.com/Galxe/gravity-aptos?rev=10c4553b16aead745e1701db7885a39313607b26#10c4553b16aead745e1701db7885a39313607b26" +source = "git+https://github.com/Galxe/gravity-aptos?rev=d163fa649970c7c8446dbc41a367c2d0b960cca6#d163fa649970c7c8446dbc41a367c2d0b960cca6" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 34b0ce4962..2f5b9e7ff1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -366,7 +366,7 @@ incremental = false [workspace.dependencies] # reth -gravity-api-types = { package = "api-types", git = "https://github.com/Galxe/gravity-aptos", rev = "10c4553b16aead745e1701db7885a39313607b26" } +gravity-api-types = { package = "api-types", git = "https://github.com/Galxe/gravity-aptos", rev = "d163fa649970c7c8446dbc41a367c2d0b960cca6" } reth = { path = "bin/reth" } reth-storage-rpc-provider = { path = "crates/storage/rpc-provider" } reth-basic-payload-builder = { path = "crates/payload/basic" } diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/errors.rs b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/errors.rs index 2538c0795a..1ce87d58ed 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/errors.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/errors.rs @@ -59,7 +59,7 @@ sol! { /// @notice Batch arrays have mismatched lengths error OracleBatchArrayLengthMismatch(uint256 noncesLength, uint256 payloadsLength, uint256 gasLimitsLength); - // -------------------- JWKManager Errors (for reference, callback failures don't revert main tx) -------------------- + // -------------------- JWKManager Errors (for reference) -------------------- /// @notice JWK version must be strictly increasing error JWKVersionNotIncreasing(bytes issuer, uint64 currentVersion, uint64 providedVersion); } diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_oracle.rs b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_oracle.rs index 401e6cd22d..ad10cb2285 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_oracle.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/jwk_oracle.rs @@ -4,8 +4,9 @@ //! - RSA JWKs: rejected here because the active execution path uses UnsupportedJWK payloads //! - UnsupportedJWK: NativeOracle.recordBatch() for oracle payloads //! -//! For blockchain events, the payload from relayer is ABI-encoded and passed through unchanged. -//! This ensures byte-exact match between relayer, on-chain storage, and read-back for comparison. +//! Relayer wrappers carry `(nonce, source_position, resolver_payload)`. The +//! `blockNumber(s)` ABI names are retained for compatibility, but the value is +//! source-defined and NativeOracle stores only the latest progress checkpoint. use super::{new_system_call_txn, NATIVE_ORACLE_ADDR}; use alloy_primitives::{Bytes, U256}; @@ -190,12 +191,12 @@ fn construct_unsupported_oracle_batch_transaction( // Build batch arrays let mut nonces: Vec = Vec::with_capacity(jwks.len()); - let mut block_numbers: Vec = Vec::with_capacity(jwks.len()); + let mut source_positions: Vec = Vec::with_capacity(jwks.len()); let mut payloads: Vec = Vec::with_capacity(jwks.len()); let mut gas_limits: Vec = Vec::with_capacity(jwks.len()); for (idx, jwk) in jwks.iter().enumerate() { - let (event_nonce, block_number, inner_payload) = + let (event_nonce, source_position, inner_payload) = match extract_nonce_block_and_payload(&jwk.data) { Some((nonce, block_num, payload)) => (nonce, block_num, payload), None => { @@ -204,17 +205,17 @@ fn construct_unsupported_oracle_batch_transaction( idx = idx, payload_len = jwk.data.len(), payload_hex = %hex::encode(&jwk.data), - "Failed to extract nonce, block_number, and payload" + "Failed to extract nonce, source position, and payload" ); return Err(format!( - "Failed to extract nonce, block_number, and payload at index {}", + "Failed to extract nonce, source position, and payload at index {}", idx )); } }; nonces.push(event_nonce); - block_numbers.push(block_number); + source_positions.push(source_position); // Use the inner payload (the original resolver payload) // This is what the user put in and what gets passed to the callback payloads.push(inner_payload.into()); @@ -223,7 +224,7 @@ fn construct_unsupported_oracle_batch_transaction( debug!( idx = idx, event_nonce = event_nonce, - ?block_number, + ?source_position, inner_payload_len = payloads.last().map(|p: &Bytes| p.len()).unwrap_or(0), "Added event to batch" ); @@ -242,7 +243,7 @@ fn construct_unsupported_oracle_batch_transaction( sourceType: source_type, sourceId: U256::from(source_id), nonces, - blockNumbers: block_numbers, + blockNumbers: source_positions, payloads, callbackGasLimits: gas_limits, }; diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_state.rs b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_state.rs index 1436c12426..4b18d2c23a 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_state.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/oracle_state.rs @@ -1,8 +1,7 @@ -//! Oracle State Fetcher +//! Oracle source progress fetcher. //! -//! This module provides functionality to fetch the latest DataRecord from NativeOracle -//! for registered oracle tasks. It builds on top of oracle_task_helpers to get source -//! information and then fetches the corresponding data records. +//! New NativeOracle deployments expose one fixed-size progress checkpoint per +//! source. The legacy record fallback keeps pre-hardfork blocks readable. use super::{ base::OnchainConfigFetcher, @@ -14,26 +13,28 @@ use alloy_primitives::{Bytes, U256}; use alloy_rpc_types_eth::TransactionRequest; use alloy_sol_macro::sol; use alloy_sol_types::SolCall; -use gravity_api_types::on_chain_config::oracle_state::{LatestDataRecord, OracleSourceState}; +use gravity_api_types::on_chain_config::oracle_state::OracleSourceState; use reth_rpc_eth_api::{helpers::EthCall, RpcTypes}; use tracing::{info, warn}; -// ============================================================================= -// ABI Definitions -// ============================================================================= - sol! { - /// DataRecord struct matching INativeOracle.DataRecord + struct SourceProgress { + uint128 latestNonce; + uint128 latestPosition; + } + + function getSourceProgress( + uint32 sourceType, + uint256 sourceId + ) external view returns (SourceProgress memory progress); + + /// Legacy record retained for pre-hardfork state fallback. struct DataRecord { - /// Timestamp when this was recorded (0 = not exists) uint64 recordedAt; - /// Block number when this was created uint256 blockNumber; - /// Stored payload data bytes data; } - /// Get a record by its key tuple function getRecord( uint32 sourceType, uint256 sourceId, @@ -41,11 +42,6 @@ sol! { ) external view returns (DataRecord memory record); } -// ============================================================================= -// Oracle State Client -// ============================================================================= - -/// Client for fetching oracle state (latest records) from NativeOracle #[derive(Debug)] pub struct OracleStateFetcher<'a, EthApi> { base_fetcher: &'a OnchainConfigFetcher, @@ -60,67 +56,59 @@ where Self { base_fetcher } } - /// Call NativeOracle.getRecord() to fetch a specific record - pub fn call_get_record( + pub fn call_get_source_progress( &self, source_type: u32, source_id: U256, - nonce: u128, block_id: BlockId, - ) -> Option { - let call = getRecordCall { sourceType: source_type, sourceId: source_id, nonce }; + ) -> Option { + let call = getSourceProgressCall { sourceType: source_type, sourceId: source_id }; let input: Bytes = call.abi_encode().into(); - let result = self.base_fetcher.eth_call(SYSTEM_CALLER, NATIVE_ORACLE_ADDR, input, block_id).ok()?; - - getRecordCall::abi_decode_returns(&result).ok() + getSourceProgressCall::abi_decode_returns(&result).ok() } - /// Fetch the latest DataRecord for a source - /// - /// Uses the latest nonce to fetch the corresponding record. - /// Returns None if nonce is 0 (no records exist). - pub fn fetch_latest_record( + pub fn call_get_record( &self, source_type: u32, source_id: U256, - latest_nonce: u128, + nonce: u128, block_id: BlockId, - ) -> Option { - self.try_fetch_latest_record(source_type, source_id, latest_nonce, block_id).flatten() + ) -> Option { + let call = getRecordCall { sourceType: source_type, sourceId: source_id, nonce }; + let input: Bytes = call.abi_encode().into(); + let result = + self.base_fetcher.eth_call(SYSTEM_CALLER, NATIVE_ORACLE_ADDR, input, block_id).ok()?; + getRecordCall::abi_decode_returns(&result).ok() } - /// Fetch the latest record while preserving the distinction between a failed read and a - /// successful read of an intentionally unstored record (`StorageSkipped`). - fn try_fetch_latest_record( + /// Read the new progress getter, falling back to legacy nonce and record state. + fn try_fetch_source_progress( &self, + task_client: &OracleTaskClient<'_, EthApi>, source_type: u32, source_id: U256, - latest_nonce: u128, block_id: BlockId, - ) -> Option> { + ) -> Option<(u128, u128)> { + if let Some(progress) = self.call_get_source_progress(source_type, source_id, block_id) { + return Some((progress.latestNonce, progress.latestPosition)); + } + + let latest_nonce = task_client.call_get_latest_nonce(source_type, source_id, block_id)?; if latest_nonce == 0 { - return Some(None); + return Some((0, 0)); } let record = self.call_get_record(source_type, source_id, latest_nonce, block_id)?; - - // Check if record exists (recordedAt > 0) if record.recordedAt == 0 { - return Some(None); + return Some((latest_nonce, 0)); } - Some(Some(LatestDataRecord { - recorded_at: record.recordedAt, - block_number: record.blockNumber.try_into().ok()?, - data: record.data.to_vec(), - })) + Some((latest_nonce, record.blockNumber.try_into().ok()?)) } - /// Fetch all oracle source states for registered relayer-backed tasks. - /// - /// Returns BCS-serialized OracleSourceStates for registered sources. + /// Fetch all relayer-backed source states as a BCS snapshot. pub fn fetch(&self, block_id: BlockId) -> Option { let task_client = OracleTaskClient::new(self.base_fetcher); let results = collect_source_states( @@ -137,31 +125,17 @@ where source_ids }, |source_type, source_id| { - let latest_nonce = - task_client.call_get_latest_nonce(source_type, source_id, block_id); - if latest_nonce.is_none() { - warn!( - target: "oracle_state", - source_type, - source_id = source_id.to_string(), - "Failed to fetch or decode latest oracle nonce" - ); - } - latest_nonce - }, - |source_type, source_id, latest_nonce| { - let latest_record = - self.try_fetch_latest_record(source_type, source_id, latest_nonce, block_id); - if latest_record.is_none() { + let progress = + self.try_fetch_source_progress(&task_client, source_type, source_id, block_id); + if progress.is_none() { warn!( target: "oracle_state", source_type, source_id = source_id.to_string(), - latest_nonce, - "Failed to fetch or decode latest oracle record" + "Failed to fetch or decode oracle source progress" ); } - latest_record + progress }, )?; @@ -177,7 +151,6 @@ where .ok() } - /// Fetch a specific source's state by source ID pub fn fetch_source_state( &self, source_type: u32, @@ -185,38 +158,32 @@ where block_id: BlockId, ) -> OracleSourceState { let task_client = OracleTaskClient::new(self.base_fetcher); - - let latest_nonce = - task_client.call_get_latest_nonce(source_type, source_id, block_id).unwrap_or(0); - - let latest_record = - self.fetch_latest_record(source_type, source_id, latest_nonce, block_id); + let (latest_nonce, latest_position) = self + .try_fetch_source_progress(&task_client, source_type, source_id, block_id) + .unwrap_or((0, 0)); OracleSourceState { source_type, source_id: source_id.try_into().unwrap_or(0), latest_nonce, - latest_record, + latest_position, } } } -fn collect_source_states( +fn collect_source_states( source_types: &[u32], mut source_ids_for: SourceIds, - mut latest_nonce_for: LatestNonce, - mut latest_record_for: LatestRecord, + mut progress_for: Progress, ) -> Option> where SourceIds: FnMut(u32) -> Option>, - LatestNonce: FnMut(u32, U256) -> Option, - LatestRecord: FnMut(u32, U256, u128) -> Option>, + Progress: FnMut(u32, U256) -> Option<(u128, u128)>, { let mut results = Vec::new(); for source_type in source_types { let source_ids = source_ids_for(*source_type)?; - info!( target: "oracle_state", source_type, @@ -225,19 +192,13 @@ where ); for source_id in source_ids { - let latest_nonce = latest_nonce_for(*source_type, source_id)?; - let latest_record = if latest_nonce == 0 { - None - } else { - latest_record_for(*source_type, source_id, latest_nonce)? - }; - + let (latest_nonce, latest_position) = progress_for(*source_type, source_id)?; info!( target: "oracle_state", source_type, source_id = source_id.to_string(), latest_nonce, - has_record = latest_record.is_some(), + latest_position, "Fetched oracle source state" ); @@ -245,7 +206,7 @@ where source_type: *source_type, source_id: source_id.try_into().ok()?, latest_nonce, - latest_record, + latest_position, }); } } @@ -263,11 +224,9 @@ mod tests { let states = collect_source_states( RELAYER_BACKED_SOURCE_TYPES, |_| Some(vec![]), - |_, _| panic!("nonce fetch must not run without sources"), - |_, _, _| panic!("record fetch must not run without sources"), + |_, _| panic!("progress fetch must not run without sources"), ) .expect("empty source lists are authoritative"); - assert!(states.is_empty()); } @@ -281,75 +240,47 @@ mod tests { calls.set(call + 1); (call != 1).then(Vec::new) }, - |_, _| panic!("nonce fetch must not run without sources"), - |_, _, _| panic!("record fetch must not run without sources"), + |_, _| panic!("progress fetch must not run without sources"), ); - assert!(states.is_none()); assert_eq!(calls.get(), 2); } #[test] - fn nonce_read_failure_invalidates_whole_snapshot() { + fn progress_read_failure_invalidates_whole_snapshot() { let source_type = RELAYER_BACKED_SOURCE_TYPES[0]; let source_id = U256::from(42); - let states = collect_source_states( - &[source_type], - |_| Some(vec![source_id]), - |_, _| None, - |_, _, _| panic!("record fetch must not run after nonce failure"), - ); - + let states = collect_source_states(&[source_type], |_| Some(vec![source_id]), |_, _| None); assert!(states.is_none()); } #[test] - fn latest_record_read_failure_invalidates_nonzero_nonce_snapshot() { + fn progress_is_included_without_payload_history() { let source_type = RELAYER_BACKED_SOURCE_TYPES[0]; let source_id = U256::from(42); let states = collect_source_states( &[source_type], |_| Some(vec![source_id]), - |_, _| Some(7), - |_, _, _| None, - ); - - assert!(states.is_none()); - } - - #[test] - fn storage_skipped_is_valid_for_nonzero_nonce() { - let source_type = RELAYER_BACKED_SOURCE_TYPES[0]; - let source_id = U256::from(42); - let states = collect_source_states( - &[source_type], - |_| Some(vec![source_id]), - |_, _| Some(7), - |_, _, _| Some(None), + |_, _| Some((7, 12_345)), ) - .expect("an intentionally unstored record is an authoritative state"); + .expect("progress is authoritative"); assert_eq!(states.len(), 1); + assert_eq!(states[0].source_type, source_type); + assert_eq!(states[0].source_id, 42); assert_eq!(states[0].latest_nonce, 7); - assert!(states[0].latest_record.is_none()); + assert_eq!(states[0].latest_position, 12_345); } #[test] - fn zero_nonce_is_valid_without_a_record() { + fn zero_progress_is_valid() { let source_type = RELAYER_BACKED_SOURCE_TYPES[0]; let source_id = U256::from(42); - let states = collect_source_states( - &[source_type], - |_| Some(vec![source_id]), - |_, _| Some(0), - |_, _, _| panic!("record fetch must not run for nonce zero"), - ) - .expect("zero nonce is a valid empty source state"); + let states = + collect_source_states(&[source_type], |_| Some(vec![source_id]), |_, _| Some((0, 0))) + .expect("zero progress is a valid empty source state"); - assert_eq!(states.len(), 1); - assert_eq!(states[0].source_type, source_type); - assert_eq!(states[0].source_id, 42); assert_eq!(states[0].latest_nonce, 0); - assert!(states[0].latest_record.is_none()); + assert_eq!(states[0].latest_position, 0); } } diff --git a/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md b/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md index b6836f4f6a..f1e514af35 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md +++ b/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md @@ -228,8 +228,10 @@ vector match Polygon. Wrong-chain endpoints and malformed matching logs fail closed. An empty result usually means the condition, CTF address, exclusive cursor, or finalized height does not cover the event; the provider must support the `finalized` block tag. -If a callback fails, the raw record remains available for -`replaySettlement(mirrorId, nonce)`. +Callback execution and NativeOracle progress advancement are atomic. If a +callback fails, the delivery reverts without advancing its nonce; validators +retry the same consensus payload after the callback or configuration problem is +fixed. NativeOracle does not append raw payload history. The deterministic SDK suite covers the full consensus, execution, resolver, market-settlement, and claim path: diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/blockchain_source.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/blockchain_source.rs index 1dc0f01551..6353756167 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/blockchain_source.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/blockchain_source.rs @@ -124,6 +124,27 @@ impl BlockchainEventSource { portal_address: Address, cursor: u64, latest_onchain_nonce: u128, + ) -> Result { + let latest_position = (latest_onchain_nonce > 0).then_some(cursor).unwrap_or(0); + Self::new_with_progress( + chain_id, + rpc_url, + portal_address, + cursor, + latest_onchain_nonce, + latest_position, + ) + .await + } + + /// Create with separate local scan and confirmed source positions. + pub async fn new_with_progress( + chain_id: u64, + rpc_url: &str, + portal_address: Address, + cursor: u64, + latest_onchain_nonce: u128, + latest_position: u64, ) -> Result { let rpc_client = Arc::new(EthHttpCli::new(rpc_url)?); @@ -133,6 +154,7 @@ impl BlockchainEventSource { portal_address = ?portal_address, cursor = cursor, latest_onchain_nonce = latest_onchain_nonce, + latest_position, "Created BlockchainEventSource with persisted cursor (fast restart)" ); @@ -141,7 +163,10 @@ impl BlockchainEventSource { rpc_client, portal_address, cursor: AtomicU64::new(cursor), - last_processed: Mutex::new(LastProcessedEvent::new(latest_onchain_nonce, cursor)), + last_processed: Mutex::new(LastProcessedEvent::new( + latest_onchain_nonce, + latest_position, + )), }) } @@ -265,6 +290,9 @@ impl OracleDataSource for BlockchainEventSource { } else { continue; }; + let source_position: u64 = block_number + .try_into() + .map_err(|_| anyhow!("MessageSent block number exceeds u64"))?; // Strictly monotonic check: ignore events we've already processed if nonce <= last_nonce { @@ -310,16 +338,20 @@ impl OracleDataSource for BlockchainEventSource { "Found new MessageSent event - decoded and re-encoded" ); - results.push(OracleData { nonce, payload: Bytes::from(encoded_payload) }); + results.push(OracleData { + nonce, + source_position, + payload: Bytes::from(encoded_payload), + }); } // Update cursor self.cursor.store(to_block, Ordering::Relaxed); // Track max nonce for exactly-once semantics (atomic update of nonce + block) - if !results.is_empty() { - let max_nonce = results.iter().map(|d| d.nonce).max().unwrap(); - *self.last_processed.lock().await = LastProcessedEvent::new(max_nonce, to_block); + if let Some(last) = results.iter().max_by_key(|data| data.nonce) { + *self.last_processed.lock().await = + LastProcessedEvent::new(last.nonce, last.source_position); } let current_last_nonce = self.last_nonce().await; diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/data_source.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/data_source.rs index 82c63bdc55..3b104d3ed3 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/data_source.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/data_source.rs @@ -21,7 +21,10 @@ pub struct OracleData { /// - For Blockchain: MessageSent.nonce pub nonce: u128, - /// ABI-encoded payload to be stored in NativeOracle + /// Source-defined restart position committed with this delivery + pub source_position: u64, + + /// ABI-encoded consensus payload delivered to NativeOracle pub payload: Bytes, } @@ -83,7 +86,7 @@ impl DataSourceKind { } } - pub(crate) async fn last_nonce_block(&self) -> Option { + pub(crate) async fn last_nonce_position(&self) -> Option { match self { Self::Blockchain(source) => source.last_nonce_block().await, Self::PriceFeed(source) => source.last_nonce_block().await, diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/oracle_manager.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/oracle_manager.rs index 5aeebac38f..49b3374cc6 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/oracle_manager.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/oracle_manager.rs @@ -25,21 +25,21 @@ pub use gravity_api_types::{on_chain_config::jwks::JWKStruct, relayer::PollResul #[derive(Debug)] enum StartupScenario { /// Persisted state exists but is stale - fast-forward to on-chain state - FastForward { onchain_nonce: u128, onchain_block: u64, persisted_nonce: u128 }, + FastForward { onchain_nonce: u128, onchain_position: u64, persisted_nonce: u128 }, /// Persisted state is ahead of NativeOracle, so it may only represent data /// fetched locally and not yet accepted on-chain. Rewind to the last /// confirmed on-chain record. RollbackToOnChain { onchain_nonce: u128, - onchain_block: u64, + onchain_position: u64, persisted_nonce: u128, persisted_cursor: u64, restart_cursor: u64, }, /// Persisted state is valid - use it for fast restart - Restore { cursor: u64, nonce: u128 }, + Restore { cursor: u64, nonce: u128, position: u64 }, /// No persisted state, but on-chain has data - sync from on-chain - ColdStartWithSync { onchain_nonce: u128, onchain_block: u64 }, + ColdStartWithSync { onchain_nonce: u128, onchain_position: u64 }, /// No persisted state, no on-chain data - start from config default ColdStart { from_block: u64 }, } @@ -49,63 +49,71 @@ impl StartupScenario { fn determine( persisted: Option<&SourceState>, onchain_nonce: u128, - onchain_block: u64, + onchain_position: u64, default_from_block: u64, ) -> Self { match persisted { Some(state) if onchain_nonce > state.last_nonce as u128 => Self::FastForward { onchain_nonce, - onchain_block, + onchain_position, persisted_nonce: state.last_nonce as u128, }, Some(state) if state.last_nonce as u128 > onchain_nonce => Self::RollbackToOnChain { onchain_nonce, - onchain_block, + onchain_position, persisted_nonce: state.last_nonce as u128, persisted_cursor: state.cursor_block, - restart_cursor: if onchain_nonce > 0 { onchain_block } else { default_from_block }, + restart_cursor: if onchain_nonce > 0 { + onchain_position + } else { + default_from_block + }, }, - Some(state) => { - Self::Restore { cursor: state.cursor_block, nonce: state.last_nonce as u128 } + Some(state) => Self::Restore { + cursor: state.cursor_block, + nonce: state.last_nonce as u128, + position: onchain_position, + }, + None if onchain_nonce > 0 => { + Self::ColdStartWithSync { onchain_nonce, onchain_position } } - None if onchain_nonce > 0 => Self::ColdStartWithSync { onchain_nonce, onchain_block }, None => Self::ColdStart { from_block: default_from_block }, } } - /// Get (cursor, nonce) for source initialization - fn into_init_params(self) -> (u64, u128) { + /// Get (scan cursor, nonce, confirmed source position) for initialization. + fn into_init_params(self) -> (u64, u128, u64) { match self { - Self::FastForward { onchain_nonce, onchain_block, .. } => { - (onchain_block, onchain_nonce) + Self::FastForward { onchain_nonce, onchain_position, .. } => { + (onchain_position, onchain_nonce, onchain_position) } - Self::RollbackToOnChain { onchain_nonce, restart_cursor, .. } => { - (restart_cursor, onchain_nonce) + Self::RollbackToOnChain { onchain_nonce, onchain_position, restart_cursor, .. } => { + (restart_cursor, onchain_nonce, onchain_position) } - Self::Restore { cursor, nonce } => (cursor, nonce), - Self::ColdStartWithSync { onchain_nonce, onchain_block } => { - (onchain_block, onchain_nonce) + Self::Restore { cursor, nonce, position } => (cursor, nonce, position), + Self::ColdStartWithSync { onchain_nonce, onchain_position } => { + (onchain_position, onchain_nonce, onchain_position) } - Self::ColdStart { from_block } => (from_block, 0), + Self::ColdStart { from_block } => (from_block, 0, 0), } } /// Log the startup scenario fn log(&self, uri: &str) { match self { - Self::FastForward { onchain_nonce, onchain_block, persisted_nonce } => { + Self::FastForward { onchain_nonce, onchain_position, persisted_nonce } => { warn!( target: "oracle_manager", uri, persisted_nonce, onchain_nonce, - onchain_block, + onchain_position, "Persisted state is stale, fast-forwarding to on-chain state" ); } Self::RollbackToOnChain { onchain_nonce, - onchain_block, + onchain_position, persisted_nonce, persisted_cursor, restart_cursor, @@ -116,26 +124,27 @@ impl StartupScenario { persisted_nonce, persisted_cursor, onchain_nonce, - onchain_block, + onchain_position, restart_cursor, "Persisted state is ahead of NativeOracle; rolling back to confirmed on-chain progress" ); } - Self::Restore { cursor, nonce } => { + Self::Restore { cursor, nonce, position } => { info!( target: "oracle_manager", uri, persisted_nonce = nonce, cursor_block = cursor, + source_position = position, "Using persisted state for fast restart" ); } - Self::ColdStartWithSync { onchain_nonce, onchain_block } => { + Self::ColdStartWithSync { onchain_nonce, onchain_position } => { info!( target: "oracle_manager", uri, onchain_nonce, - onchain_block, + onchain_position, "Cold start with on-chain state" ); } @@ -180,13 +189,13 @@ impl OracleRelayerManager { /// * `uri` - The oracle task URI /// * `rpc_url` - RPC endpoint URL /// * `onchain_nonce` - Latest nonce from NativeOracle - /// * `onchain_block_number` - Block number where onchain_nonce was recorded + /// * `onchain_position` - Source-defined position committed with onchain_nonce pub async fn add_uri( &self, uri: &str, rpc_url: &str, onchain_nonce: u128, - onchain_block_number: u64, + onchain_position: u64, ) -> Result<()> { { let sources = self.sources.read().await; @@ -204,16 +213,23 @@ impl OracleRelayerManager { StartupScenario::determine( state.get(uri), onchain_nonce, - onchain_block_number, + onchain_position, task.from_block(), ) }; scenario.log(uri); - let (start_cursor, start_nonce) = scenario.into_init_params(); + let (start_cursor, start_nonce, start_position) = scenario.into_init_params(); // Create source with reconciled state - let source = - self.create_source_from_task(&task, rpc_url, start_nonce, Some(start_cursor)).await?; + let source = self + .create_source_from_task( + &task, + rpc_url, + start_nonce, + start_position, + Some(start_cursor), + ) + .await?; info!( target: "oracle_manager", @@ -222,6 +238,7 @@ impl OracleRelayerManager { source_id = task.source_id, start_nonce = start_nonce, start_cursor = start_cursor, + start_position = start_position, "Added data source" ); @@ -235,18 +252,20 @@ impl OracleRelayerManager { task: &ParsedOracleTask, rpc_url: &str, latest_onchain_nonce: u128, + latest_onchain_position: u64, persisted_cursor: Option, ) -> Result { match task.source_type { source_types::BLOCKCHAIN => { let portal_address = task.portal_address()?; let config_start_block = task.from_block(); - let source = BlockchainEventSource::new_with_cursor( + let source = BlockchainEventSource::new_with_progress( task.source_id, rpc_url, portal_address, persisted_cursor.unwrap_or(config_start_block), latest_onchain_nonce, + latest_onchain_position, ) .await?; @@ -257,16 +276,17 @@ impl OracleRelayerManager { task, latest_onchain_nonce, Some(rpc_url), - persisted_cursor, + (latest_onchain_nonce > 0).then_some(latest_onchain_position), )?; Ok(DataSourceKind::PriceFeed(source)) } source_types::POLYMARKET_SETTLEMENT => { - let source = PolymarketSettlementSource::from_task( + let source = PolymarketSettlementSource::from_task_with_progress( task, rpc_url, latest_onchain_nonce, persisted_cursor.unwrap_or(task.from_block()), + latest_onchain_position, ) .await?; Ok(DataSourceKind::PolymarketSettlement(source)) @@ -283,18 +303,18 @@ impl OracleRelayerManager { /// # Arguments /// * `uri` - The oracle task URI to poll /// * `onchain_nonce` - Optional current on-chain nonce for reconciliation - /// * `onchain_block_number` - Optional on-chain block for reconciliation + /// * `onchain_position` - Optional confirmed source-defined position pub async fn poll_uri( &self, uri: &str, onchain_nonce: Option, - onchain_block_number: Option, + onchain_position: Option, ) -> Result { let sources = self.sources.read().await; let source = sources.get(uri).ok_or_else(|| anyhow!("Source not found: {}", uri))?; // Reconcile with on-chain state before polling. - if let (Some(onchain_nonce), Some(onchain_block)) = (onchain_nonce, onchain_block_number) { + if let (Some(onchain_nonce), Some(onchain_position)) = (onchain_nonce, onchain_position) { let current_nonce = source.last_nonce().await.unwrap_or(0); if onchain_nonce > current_nonce { info!( @@ -302,10 +322,10 @@ impl OracleRelayerManager { uri, local_nonce = current_nonce, onchain_nonce, - onchain_block, + onchain_position, "On-chain state is ahead of local source; fast-forwarding" ); - source.fast_forward(onchain_nonce, onchain_block).await; + source.fast_forward(onchain_nonce, onchain_position).await; } } @@ -313,7 +333,7 @@ impl OracleRelayerManager { // Get nonce, cursor, and source info let nonce = source.last_nonce().await; - let last_nonce_block = source.last_nonce_block().await; + let last_nonce_position = source.last_nonce_position().await; let max_block_number = source.cursor(); let source_type = source.source_type(); let source_id = source.source_id_u64(); @@ -344,7 +364,7 @@ impl OracleRelayerManager { source_type, source_id, n, - last_nonce_block.unwrap_or(0), + last_nonce_position.unwrap_or(0), max_block_number, ) .await; @@ -455,10 +475,11 @@ mod tests { let scenario = StartupScenario::determine(state.get(polymarket_uri()), 3, 50_000_007, 50_000_000); - let (cursor, nonce) = scenario.into_init_params(); + let (cursor, nonce, position) = scenario.into_init_params(); assert_eq!(cursor, 50_000_007); assert_eq!(nonce, 3); + assert_eq!(position, 50_000_007); } #[test] @@ -474,9 +495,31 @@ mod tests { ); let scenario = StartupScenario::determine(state.get(polymarket_uri()), 0, 0, 50_000_000); - let (cursor, nonce) = scenario.into_init_params(); + let (cursor, nonce, position) = scenario.into_init_params(); assert_eq!(cursor, 50_000_000); assert_eq!(nonce, 0); + assert_eq!(position, 0); + } + + #[test] + fn test_startup_restores_local_watermark_with_onchain_position() { + let mut state = RelayerState::new(); + state.update( + polymarket_uri(), + source_types::POLYMARKET_SETTLEMENT, + 42, + 3, + 50_000_010, + 50_000_020, + ); + + let scenario = + StartupScenario::determine(state.get(polymarket_uri()), 3, 50_000_007, 50_000_000); + let (cursor, nonce, position) = scenario.into_init_params(); + + assert_eq!(cursor, 50_000_020); + assert_eq!(nonce, 3); + assert_eq!(position, 50_000_007); } } diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/persistence.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/persistence.rs index 4434fd5be0..297e1e362e 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/persistence.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/persistence.rs @@ -26,7 +26,9 @@ pub struct SourceState { pub source_id: u64, /// Last nonce we fetched and returned pub last_nonce: u128, - /// Block number where last_nonce was emitted + /// Source-defined position committed with last_nonce. + /// + /// The field name is retained for backward-compatible JSON persistence. pub last_nonce_block: u64, /// Current polling cursor (block number) pub cursor_block: u64, diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/polymarket_settlement_source.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/polymarket_settlement_source.rs index 6ae75057bd..3e01e0f252 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/polymarket_settlement_source.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/polymarket_settlement_source.rs @@ -142,6 +142,18 @@ impl PolymarketSettlementSource { rpc_url: &str, latest_onchain_nonce: u128, cursor: u64, + ) -> Result { + let latest_position = (latest_onchain_nonce > 0).then_some(cursor).unwrap_or(0); + Self::from_task_with_progress(task, rpc_url, latest_onchain_nonce, cursor, latest_position) + .await + } + + pub(crate) async fn from_task_with_progress( + task: &ParsedOracleTask, + rpc_url: &str, + latest_onchain_nonce: u128, + cursor: u64, + latest_position: u64, ) -> Result { if task.source_type != source_types::POLYMARKET_SETTLEMENT { return Err(anyhow!( @@ -180,6 +192,7 @@ impl PolymarketSettlementSource { max_blocks_per_poll, cursor, latest_onchain_nonce, + latest_position, "Created PolymarketSettlementSource" ); @@ -194,7 +207,7 @@ impl PolymarketSettlementSource { cursor: AtomicU64::new(cursor), last_settlement: Mutex::new(LastSettlement { nonce: latest_onchain_nonce, - block: cursor, + block: latest_position, }), }) } @@ -369,7 +382,11 @@ fn observations_to_oracle_data( let nonce = starting_nonce .checked_add(offset) .ok_or_else(|| anyhow!("Polymarket settlement nonce overflow"))?; - Ok(OracleData { nonce, payload: obs.wrapped_payload(nonce) }) + Ok(OracleData { + nonce, + source_position: obs.block_number, + payload: obs.wrapped_payload(nonce), + }) }) .collect() } diff --git a/crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs b/crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs index d2e365dc75..6b6347e381 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs @@ -327,7 +327,11 @@ impl OracleDataSource for PriceFeedSource { state.block = round.block_number; self.cursor.store(round.block_number, Ordering::Relaxed); - Ok(vec![OracleData { nonce: round.delivery_nonce, payload: Bytes::from(wrapped_payload) }]) + Ok(vec![OracleData { + nonce: round.delivery_nonce, + source_position: round.block_number, + payload: Bytes::from(wrapped_payload), + }]) } } From def4dda303a7831b6775227be4e9057d6ecb639b Mon Sep 17 00:00:00 2001 From: ByteYue Date: Thu, 30 Jul 2026 16:25:19 +0800 Subject: [PATCH 10/11] fix(build): keep trie dependencies rust 1.93 compatible --- Cargo.lock | 36 ++++++++++++---------- Cargo.toml | 2 +- crates/trie/common/src/nested_trie/node.rs | 8 ++--- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a8e91d4af..077687e5b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -965,13 +965,14 @@ dependencies = [ [[package]] name = "alloy-trie" -version = "0.9.5" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f14b5d9b2c2173980202c6ff470d96e7c5e202c65a9f67884ad565226df7fbb" +checksum = "4d7fd448ab0a017de542de1dcca7a58e7019fe0e7a34ed3f9543ebddf6aceffa" dependencies = [ "alloy-primitives", "alloy-rlp", "arbitrary", + "arrayvec", "derive_arbitrary", "derive_more", "nybbles", @@ -1046,7 +1047,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1057,7 +1058,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1427,6 +1428,9 @@ name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", +] [[package]] name = "asn1_der" @@ -3011,7 +3015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -3499,7 +3503,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4256,8 +4260,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -4952,7 +4956,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -5309,7 +5313,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6367,7 +6371,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -11465,7 +11469,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -11545,7 +11549,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs 1.0.7", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -12202,7 +12206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -12429,7 +12433,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -13739,7 +13743,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2f5b9e7ff1..1c4fd27577 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -514,7 +514,7 @@ alloy-eip2124 = { version = "0.2.0", default-features = false } alloy-eip7928 = { version = "0.4.0", default-features = false, features = ["rlp"] } alloy-evm = { version = "0.36.0", default-features = false } alloy-rlp = { version = "0.3.13", default-features = false, features = ["core-net"] } -alloy-trie = { version = "0.9.4", default-features = false } +alloy-trie = { version = "=0.9.4", default-features = false } alloy-hardforks = "0.4.7" diff --git a/crates/trie/common/src/nested_trie/node.rs b/crates/trie/common/src/nested_trie/node.rs index 47f1c0906a..7124d78068 100644 --- a/crates/trie/common/src/nested_trie/node.rs +++ b/crates/trie/common/src/nested_trie/node.rs @@ -35,13 +35,13 @@ impl NodeFlag { } /// Mark current node as dirty, and wipe the cached hash - pub const fn mark_dirty(&mut self) { + pub fn mark_dirty(&mut self) { self.rlp = None; self.dirty = true; } /// Reset current node - pub const fn reset(&mut self) { + pub fn reset(&mut self) { self.rlp = None; self.dirty = false; } @@ -364,7 +364,7 @@ impl Node { } /// Set cached hash - pub const fn set_rlp(&mut self, rlp: RlpNode) { + pub fn set_rlp(&mut self, rlp: RlpNode) { match self { Self::FullNode { children: _, flags } | Self::ShortNode { key: _, value: _, flags } => { flags.rlp = Some(rlp); @@ -385,7 +385,7 @@ impl Node { } /// Reset current node - pub const fn reset(mut self) -> Self { + pub fn reset(mut self) -> Self { match &mut self { Self::FullNode { children: _, flags } | Self::ShortNode { key: _, value: _, flags } => { flags.reset() From a5cf019429d772cd2f4964fc0705676d84464953 Mon Sep 17 00:00:00 2001 From: ByteYue Date: Thu, 30 Jul 2026 16:27:43 +0800 Subject: [PATCH 11/11] fix(build): pin reth core crate compatibility --- Cargo.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1c4fd27577..c9a4e06c60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -376,7 +376,7 @@ reth-cli = { path = "crates/cli/cli" } reth-cli-commands = { path = "crates/cli/commands" } reth-cli-runner = { path = "crates/cli/runner" } reth-cli-util = { path = "crates/cli/util" } -reth-codecs = { version = "0.4.1", default-features = false } +reth-codecs = { version = "=0.4.1", default-features = false } reth-codecs-derive = "0.4.1" reth-config = { path = "crates/config", default-features = false } reth-consensus = { path = "crates/consensus/consensus", default-features = false } @@ -463,7 +463,7 @@ reth-rpc-eth-types = { path = "crates/rpc/rpc-eth-types", default-features = fal reth-rpc-layer = { path = "crates/rpc/rpc-layer" } reth-rpc-server-types = { path = "crates/rpc/rpc-server-types" } reth-rpc-convert = { path = "crates/rpc/rpc-convert" } -reth-rpc-traits = { version = "0.4.1", default-features = false } +reth-rpc-traits = { version = "=0.4.1", default-features = false } reth-stages = { path = "crates/stages/stages" } reth-stages-api = { path = "crates/stages/api" } reth-stages-types = { path = "crates/stages/types", default-features = false } @@ -489,7 +489,7 @@ gravity-primitives = { path = "crates/gravity-primitives" } gravity-precompiles = { path = "crates/gravity-precompiles" } reth-trie-sparse = { path = "crates/trie/sparse", default-features = false } reth-trie-sparse-parallel = { path = "crates/trie/sparse-parallel" } -reth-zstd-compressors = { version = "0.4.1", default-features = false } +reth-zstd-compressors = { version = "=0.4.1", default-features = false } reth-ress-protocol = { path = "crates/ress/protocol" } reth-ress-provider = { path = "crates/ress/provider" }