diff --git a/Cargo.lock b/Cargo.lock index 144ff3d69c..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", @@ -1069,7 +1070,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[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=d163fa649970c7c8446dbc41a367c2d0b960cca6#d163fa649970c7c8446dbc41a367c2d0b960cca6" dependencies = [ "anyhow", "async-trait", @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 00692717bf..c9a4e06c60 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 = "b1f68dc85781ef0d28a568d9d64604b153be9d9e" } +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" } @@ -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" } @@ -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/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_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..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 @@ -1,22 +1,27 @@ //! 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. +//! 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}; 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, source_types}; use tracing::{debug, info, warn}; -/// Default callback gas limit for oracle updates -const CALLBACK_GAS_LIMIT: u64 = 500_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) @@ -58,70 +63,46 @@ 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) -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; +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}")), } +} - // 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 { +/// 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) { + 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 +112,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 +146,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,27 +172,31 @@ 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))?; + let callback_gas_limit = callback_gas_limit(source_type)?; + 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()); + 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 => { @@ -220,26 +205,26 @@ fn construct_blockchain_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); - // Use the inner payload (the original MessageSent.payload) + 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()); - gas_limits.push(U256::from(CALLBACK_GAS_LIMIT)); + gas_limits.push(U256::from(callback_gas_limit)); 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" ); @@ -247,18 +232,18 @@ 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, + blockNumbers: source_positions, payloads, callbackGasLimits: gas_limits, }; @@ -268,3 +253,195 @@ 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_primitives::I256; + use alloy_sol_macro::sol; + use alloy_sol_types::SolValue; + + sol! { + struct PricePayloadForTest { + uint256 feedId; + uint64 roundId; + uint64 resolvedAt; + uint8 decimals; + int256 price; + } + } + + #[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=binance_index_kline_v1".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(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] + 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)]); + 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"); + } + + #[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: 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(1)); + assert_eq!(call.nonces, vec![1]); + 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, 33); + assert_eq!(payload.resolvedAt, 2_039_999); + assert_eq!(payload.decimals, 8); + assert_eq!(payload.price, "10000000000".parse::().unwrap()); + } +} 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..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,12 +1,11 @@ -//! 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, - oracle_task_helpers::{OracleTaskClient, SOURCE_TYPE_BLOCKCHAIN}, + oracle_task_helpers::{OracleTaskClient, RELAYER_BACKED_SOURCE_TYPES}, NATIVE_ORACLE_ADDR, SYSTEM_CALLER, }; use alloy_eips::BlockId; @@ -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; - -// ============================================================================= -// ABI Definitions -// ============================================================================= +use tracing::{info, warn}; 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,7 +56,19 @@ where Self { base_fetcher } } - /// Call NativeOracle.getRecord() to fetch a specific record + pub fn call_get_source_progress( + &self, + source_type: u32, + source_id: U256, + block_id: BlockId, + ) -> 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()?; + getSourceProgressCall::abi_decode_returns(&result).ok() + } + pub fn call_get_record( &self, source_type: u32, @@ -70,109 +78,209 @@ where ) -> 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 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( + /// 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 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 None; + return Some((latest_nonce, 0)); } - Some(LatestDataRecord { - recorded_at: record.recordedAt, - block_number: record.blockNumber.try_into().unwrap_or(0), - data: record.data.to_vec(), - }) + Some((latest_nonce, record.blockNumber.try_into().ok()?)) } - /// Fetch all oracle source states for registered blockchain 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 mut results = Vec::new(); + 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 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(), + "Failed to fetch or decode oracle source progress" + ); + } + progress + }, + )?; + + bcs::to_bytes(&results) + .map(Bytes::from) + .map_err(|error| { + warn!( + target: "oracle_state", + %error, + "Failed to BCS serialize OracleSourceStates" + ); + }) + .ok() + } - // Get all registered blockchain source IDs - let source_ids = task_client - .fetch_registered_source_ids(SOURCE_TYPE_BLOCKCHAIN, block_id) - .unwrap_or_default(); + pub fn fetch_source_state( + &self, + source_type: u32, + source_id: U256, + block_id: BlockId, + ) -> OracleSourceState { + let task_client = OracleTaskClient::new(self.base_fetcher); + 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_position, + } + } +} + +fn collect_source_states( + source_types: &[u32], + mut source_ids_for: SourceIds, + mut progress_for: Progress, +) -> Option> +where + SourceIds: FnMut(u32) -> 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, 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); - + 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" ); results.push(OracleSourceState { - source_type: SOURCE_TYPE_BLOCKCHAIN, - source_id: source_id.try_into().unwrap_or(0), + source_type: *source_type, + source_id: source_id.try_into().ok()?, latest_nonce, - latest_record, + latest_position, }); } + } + + Some(results) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; - Some(bcs::to_bytes(&results).expect("Failed to BCS serialize OracleSourceStates").into()) + #[test] + fn authoritative_empty_source_lists_are_valid() { + let states = collect_source_states( + RELAYER_BACKED_SOURCE_TYPES, + |_| Some(vec![]), + |_, _| panic!("progress fetch must not run without sources"), + ) + .expect("empty source lists are authoritative"); + assert!(states.is_empty()); } - /// Fetch a specific source's state by source ID - pub fn fetch_source_state( - &self, - source_type: u32, - source_id: U256, - block_id: BlockId, - ) -> OracleSourceState { - let task_client = OracleTaskClient::new(self.base_fetcher); + #[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!("progress fetch must not run without sources"), + ); + assert!(states.is_none()); + assert_eq!(calls.get(), 2); + } + + #[test] + 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); + assert!(states.is_none()); + } - let latest_nonce = - task_client.call_get_latest_nonce(source_type, source_id, block_id).unwrap_or(0); + #[test] + 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, 12_345)), + ) + .expect("progress is authoritative"); - let latest_record = - self.fetch_latest_record(source_type, source_id, latest_nonce, block_id); + 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_eq!(states[0].latest_position, 12_345); + } - OracleSourceState { - source_type, - source_id: source_id.try_into().unwrap_or(0), - latest_nonce, - latest_record, - } + #[test] + 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, 0))) + .expect("zero progress is a valid empty source state"); + + assert_eq!(states[0].latest_nonce, 0); + assert_eq!(states[0].latest_position, 0); } } 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..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 @@ -23,6 +23,20 @@ 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]; + +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; @@ -66,6 +80,29 @@ 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)); + } + + #[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)); + } +} + // ============================================================================= // OracleTaskClient - Shared Helper for Contract Calls // ============================================================================= @@ -161,20 +198,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 +233,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; }; @@ -210,14 +259,26 @@ 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_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 +291,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 +312,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..f1e514af35 --- /dev/null +++ b/crates/pipe-exec-layer-ext-v2/relayer/ORACLE_CANONICAL_PAYLOADS.md @@ -0,0 +1,241 @@ +# 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 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. + +## Binance price feed + +URI shape: + +```text +gravity://3//price_feed + ?provider=binance_index_kline_v1 + &pair= + &interval=1m + &bucketStartMs= + &decimals=8 + &graceMs= +``` + +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`: + +```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. The interval is also immutable. Use a new +`feedId` when introducing a new origin or interval. + +`round`, `resolvedAt`, and `blockNumber` are derived from the delivery 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 + +The resolver payload is the ABI encoding of one Binance close: + +```solidity +struct PricePayload { + uint256 feedId; + uint64 roundId; + uint64 resolvedAt; + uint8 decimals; + int256 price; +} +``` + +There is no provider weight, source count, threshold, aggregation mode, or +inline fixture in the source-type-3 protocol. + +## 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. + +## Verification + +```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. + +### 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. +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: + +```bash +./gravity_e2e/run_test.sh polymarket_mock --force-init +``` diff --git a/crates/pipe-exec-layer-ext-v2/relayer/README.md b/crates/pipe-exec-layer-ext-v2/relayer/README.md index c3580a5ce6..6d8fb23ce3 100644 --- a/crates/pipe-exec-layer-ext-v2/relayer/README.md +++ b/crates/pipe-exec-layer-ext-v2/relayer/README.md @@ -1,335 +1,23 @@ -# 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 +Supported runtime sources are: -### 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 +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. -### Relayer Manager (RelayerManager) -- Manages multiple relayers for different URIs -- Centralized lifecycle management -- Supports multiple RPC endpoints -- Provides unified interface for adding and polling URIs - -## 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" -``` - -### Account Activity Monitoring -```rust -// Monitor ERC20 transfers for specific address -"gravity://mainnet/account/0x123456789abcdef123456789abcdef1234567890/activity?type=erc20_transfer" - -// Monitor all transactions for specific address -"gravity://mainnet/account/0x123456789abcdef123456789abcdef1234567890/activity?type=all_transactions" -``` - -## Event Filter Parameters - -### fromBlock Parameter -The `fromBlock` parameter allows you to specify the starting block for event monitoring: - -```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 -``` - -**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); - } - } -} -``` - -## 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 +Public-network tests are ignored by default. 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..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; @@ -363,7 +395,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 +457,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..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 @@ -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 /// @@ -18,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, } @@ -47,6 +53,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 +66,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), + /// Binance index-price feed (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_position(&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 +124,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..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 @@ -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,15 +21,25 @@ 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 }, + 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_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 }, } @@ -37,65 +49,102 @@ 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_position, persisted_nonce: state.last_nonce as u128, + persisted_cursor: state.cursor_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, 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::Restore { cursor, nonce } => { + Self::RollbackToOnChain { + onchain_nonce, + onchain_position, + persisted_nonce, + persisted_cursor, + restart_cursor, + } => { + warn!( + target: "oracle_manager", + uri, + persisted_nonce, + persisted_cursor, + onchain_nonce, + onchain_position, + restart_cursor, + "Persisted state is ahead of NativeOracle; rolling back to confirmed on-chain progress" + ); + } + 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" ); } @@ -140,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; @@ -164,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", @@ -182,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" ); @@ -195,23 +252,45 @@ 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?; Ok(DataSourceKind::Blockchain(source)) } + source_types::PRICE_FEED => { + let source = PriceFeedSource::from_task_with_reconciled_cursor( + task, + latest_onchain_nonce, + Some(rpc_url), + (latest_onchain_nonce > 0).then_some(latest_onchain_position), + )?; + Ok(DataSourceKind::PriceFeed(source)) + } + source_types::POLYMARKET_SETTLEMENT => { + 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)) + } _ => Err(anyhow!("Unknown source type: {}", task.source_type)), } } @@ -224,49 +303,40 @@ 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) { - 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; - } - } + // Reconcile with on-chain state before polling. + 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!( + target: "oracle_manager", + uri, + local_nonce = current_nonce, + onchain_nonce, + onchain_position, + "On-chain state is ahead of local source; fast-forwarding" + ); + source.fast_forward(onchain_nonce, onchain_position).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_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(); let jwk_structs: Vec = data .iter() @@ -294,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; @@ -306,10 +376,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 +402,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 +430,96 @@ 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=binance_index_kline_v1&pair=TSLAUSDT&interval=1m&bucketStartMs=1710000000000&decimals=8" + } + + fn polymarket_uri() -> &'static str { + "gravity://6/42/polymarket_settlement?ctf=0x4D97DCd97eC945f40cF65F87097ACe5EA0476045&fromBlock=50000000&condition=0x1111111111111111111111111111111111111111111111111111111111111111" + } + + #[tokio::test] + 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(), "http://localhost:18547", 0, 0).await.unwrap(); + assert!(manager.has_uri(price_uri()).await); + } + + #[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, position) = scenario.into_init_params(); + + assert_eq!(cursor, 50_000_007); + assert_eq!(nonce, 3); + assert_eq!(position, 50_000_007); + } + + #[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, 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 f8a12e122d..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, @@ -51,11 +53,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 +69,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 +78,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 +132,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..3e01e0f252 --- /dev/null +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/polymarket_settlement_source.rs @@ -0,0 +1,802 @@ +//! 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 { + 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!( + "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, + latest_position, + "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: latest_position, + }), + }) + } + + /// 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, + source_position: obs.block_number, + 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..6b6347e381 --- /dev/null +++ b/crates/pipe-exec-layer-ext-v2/relayer/src/price_feed_source.rs @@ -0,0 +1,906 @@ +//! Binance index-price feed source. +//! +//! 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::{Bytes, 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 PROVIDER_BINANCE_INDEX_KLINE: &str = "binance_index_kline_v1"; +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_DECIMALS: u8 = 18; +const BINANCE_TASK_PARAMETERS: &[&str] = + &["provider", "pair", "interval", "bucketStartMs", "decimals", "graceMs"]; + +sol! { + struct PricePayloadSol { + uint256 feedId; + uint64 roundId; + uint64 resolvedAt; + uint8 decimals; + int256 price; + } +} + +#[derive(Debug, Clone, Copy, Default)] +struct LastPriceRound { + nonce: u128, + block: u64, +} + +impl LastPriceRound { + fn is_initialized(self) -> bool { + self.nonce > 0 + } +} + +#[derive(Debug, Clone)] +struct BinanceIndexKlineConfig { + base_url: String, + pair: String, + interval: String, + interval_ms: u64, + bucket_start_ms: u64, + grace_ms: u64, + decimals: u8, +} + +#[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 { + let offset = delivery_nonce + .checked_sub(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 bucket offset overflow"))?; + let bucket_start_ms = u128::from(self.bucket_start_ms) + .checked_add(offset_ms) + .ok_or_else(|| anyhow!("Binance bucket start overflow"))?; + let bucket_start_ms = u64::try_from(bucket_start_ms) + .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 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, + config: BinanceIndexKlineConfig, + client: Client, + cursor: AtomicU64, + last_round: Mutex, +} + +impl PriceFeedSource { + /// 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 source with the validator-local Binance base URL. + 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)); + } + 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(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")), + } + + if task.params.contains_key("baseUrl") { + return Err(anyhow!( + "Binance baseUrl must be validator-local relayer config, not an on-chain URI parameter" + )); + } + for parameter in task.params.keys() { + if !BINANCE_TASK_PARAMETERS.contains(¶meter.as_str()) { + return Err(anyhow!("Unsupported Binance index kline parameter '{parameter}'")); + } + } + + 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 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 decimals = parse_required::(task, "decimals")?; + if decimals > MAX_PRICE_DECIMALS { + return Err(anyhow!( + "price feed decimals {} exceeds maximum {}", + decimals, + MAX_PRICE_DECIMALS + )); + } + let grace_ms = parse_optional(task, "graceMs")?.unwrap_or(DEFAULT_BINANCE_GRACE_MS); + let base_url = binance_base_url(rpc_url)?; + 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 config = BinanceIndexKlineConfig { + base_url, + pair, + interval, + interval_ms, + bucket_start_ms, + grace_ms, + decimals, + }; + + let previous_block = if latest_onchain_nonce == 0 { + None + } 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 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 + )); + } + } + Some(expected) + }; + let last_round = previous_block + .map(|block| LastPriceRound { nonce: latest_onchain_nonce, block }) + .unwrap_or_default(); + let initial_cursor = + previous_block.unwrap_or(config.round_for_delivery_nonce(1)?.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(), + bucket_start_ms, + latest_onchain_nonce, + "Created Binance index kline PriceFeedSource" + ); + + Ok(Self { + feed_id: task.source_id, + config, + client, + cursor: AtomicU64::new(initial_cursor), + last_round: Mutex::new(last_round), + }) + } + + /// 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) + } + + /// 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) + } + + /// 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 bucket-close 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; + 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![]); + } + + 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(), + )); + + 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, + source_position: round.block_number, + payload: Bytes::from(wrapped_payload), + }]) + } +} + +async fn fetch_binance_index_kline_price( + 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_price_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_price_from_response( + config: &BinanceIndexKlineConfig, + round: &BinanceIndexKlineRound, + response: &Value, +) -> 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_close<'a>( + round: &BinanceIndexKlineRound, + response: &'a Value, +) -> Result<&'a str> { + 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(close) +} + +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 Binance index kline interval '{interval}'")), + } +} + +fn encode_price_payload( + feed_id: u64, + round_id: u64, + resolved_at: u64, + decimals: u8, + price: I256, +) -> Vec { + PricePayloadSol { + feedId: U256::from(feed_id), + roundId: round_id, + resolvedAt: resolved_at, + decimals, + price, + } + .abi_encode() +} + +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 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 binance_uri() -> &'static str { + "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 { + 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()) + } + + #[test] + 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] + fn test_binance_index_kline_source_config() { + let task = parse_oracle_uri(binance_uri()).unwrap(); + let source = + PriceFeedSource::from_task_with_rpc(&task, 0, Some("https://fapi.binance.com")) + .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); + assert_eq!( + 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_round_mapping() { + let task = parse_oracle_uri(binance_uri()).unwrap(); + let source = + PriceFeedSource::from_task_with_rpc(&task, 2, Some("https://fapi.binance.com")) + .unwrap(); + + assert_eq!(source.cursor(), 1_710_000_119_999); + 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); + } + + #[test] + fn test_binance_rejects_mismatched_history() { + let task = parse_oracle_uri(binance_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_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("Unsupported Binance index kline parameter"), + "parameter {parameter}: {err}" + ); + } + } + + #[test] + 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_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, Some("https://fapi.binance.com")) + .unwrap_err(); + + assert!(err.to_string().contains("not aligned")); + } + + #[test] + 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 response = serde_json::json!([[ + 1710000000000u64, + "400.67293", + "400.67546", + "400.67293", + "400.67545", + "0", + 1710000059999u64, + "0", + 0, + "0", + "0", + "0" + ]]); + + 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_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 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] + 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 response = serde_json::json!([[ + 1710000060000u64, + "400.67293", + "400.67546", + "400.67293", + "400.67545", + "0", + 1710000119999u64 + ]]); + + 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("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() { + 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(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&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_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()); + } +} 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..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,6 +10,11 @@ //! //! ### Examples //! - Blockchain events: `gravity://0/1/events?portal=0x283fC6...&fromBlock=9565280` +//! - 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 +27,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 +62,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 +143,34 @@ mod tests { assert!(task.portal_address().is_ok()); } + #[test] + fn test_parse_price_feed_uri() { + 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); + 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"; 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()