From 0772b8415885373a8982574825614c14030ac019 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Wed, 11 Mar 2026 12:11:53 +0000 Subject: [PATCH] feat: oracle signed context feature stack Brings the oracle signed context feature into main via a fresh branch. Includes all phases of the oracle feature: **Phase 6**: Parse oracle-url from dotrain YAML order config and encode it as RaindexSignedContextOracleV1 metadata when deploying orders **Phase 7**: extractOracleUrl static method on RaindexOrder **Phase 3**: Oracle fetch module - fetches signed context from oracle URL **Phase 4**: Frontend UI - shows oracle info on order detail page **Quote flow**: Wire signed context into quote simulation Changes made: - Added oracle module to quote crate with fetch functionality - Added oracle-url field to order YAML config parsing - Updated add_order to encode oracle metadata when deploying orders - Added oracle_url() getter and extract_oracle_url() static method to RaindexOrder - Updated quote generation to fetch oracle context automatically - Updated take order flow to use oracle context from candidates - Added oracle URL display to frontend order detail page - Updated all test cases to include oracle_url field Phase 2 (RaindexSignedContextOracleV1 type) was already in main via PR #2457. --- Cargo.lock | 16 +- crates/common/src/add_order.rs | 24 +- crates/common/src/lib.rs | 1 + crates/common/src/oracle.rs | 3 + crates/common/src/raindex_client/orders.rs | 24 ++ .../src/raindex_client/take_orders/single.rs | 20 ++ .../take_orders/single_tests.rs | 26 ++ crates/common/src/take_orders/candidates.rs | 128 ++++++--- crates/common/src/take_orders/config.rs | 2 +- crates/common/src/test_helpers.rs | 1 + crates/quote/Cargo.toml | 2 + crates/quote/src/lib.rs | 1 + crates/quote/src/oracle.rs | 257 ++++++++++++++++++ crates/quote/src/order_quotes.rs | 35 ++- crates/settings/src/order.rs | 10 +- .../lib/components/detail/OrderDetail.svelte | 24 ++ 16 files changed, 523 insertions(+), 51 deletions(-) create mode 100644 crates/common/src/oracle.rs create mode 100644 crates/quote/src/oracle.rs diff --git a/Cargo.lock b/Cargo.lock index b3e2951d7e..effe43d2c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6724,14 +6724,11 @@ dependencies = [ "alloy", "eyre", "foundry-evm", - "once_cell", "rain-error-decoding 0.1.0 (git+https://github.com/rainlanguage/rain.error?rev=3d2ed70fb2f7c6156706846e10f163d1e493a8d3)", "rain_interpreter_bindings", - "reqwest 0.11.27", "revm 24.0.1", "revm 25.0.0", "serde", - "serde_json", "thiserror 1.0.69", "wasm-bindgen-utils 0.0.10 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -6845,14 +6842,6 @@ name = "rain_interpreter_dispair" version = "0.1.0" dependencies = [ "alloy", - "alloy-ethers-typecast 0.2.0 (git+https://github.com/rainlanguage/alloy-ethers-typecast?rev=bcc3a04394aefe191fef4ae8e6e94381a419c99a)", - "rain_interpreter_bindings", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tracing", - "tracing-subscriber 0.3.19", ] [[package]] @@ -6863,8 +6852,6 @@ dependencies = [ "alloy-ethers-typecast 0.2.0 (git+https://github.com/rainlanguage/alloy-ethers-typecast?rev=bcc3a04394aefe191fef4ae8e6e94381a419c99a)", "rain_interpreter_bindings", "rain_interpreter_dispair", - "serde", - "serde_json", "thiserror 1.0.69", "tokio", ] @@ -6875,7 +6862,6 @@ version = "0.0.0" dependencies = [ "alloy", "getrandom 0.2.16", - "serde_json", ] [[package]] @@ -7067,12 +7053,14 @@ dependencies = [ "anyhow", "async-trait", "clap", + "futures", "getrandom 0.2.16", "httpmock", "once_cell", "rain-error-decoding 0.1.0 (git+https://github.com/rainlanguage/rain.error?rev=3d2ed70fb2f7c6156706846e10f163d1e493a8d3)", "rain-interpreter-eval", "rain-math-float", + "rain-metadata 0.0.2-alpha.6", "rain_orderbook_app_settings", "rain_orderbook_bindings", "rain_orderbook_common", diff --git a/crates/common/src/add_order.rs b/crates/common/src/add_order.rs index 0841e332cf..ff821f6968 100644 --- a/crates/common/src/add_order.rs +++ b/crates/common/src/add_order.rs @@ -22,8 +22,9 @@ use rain_interpreter_eval::{ }; use rain_interpreter_parser::{Parser2, ParserError, ParserV2}; use rain_metadata::{ - types::dotrain::gui_state_v1::DotrainGuiStateV1, ContentEncoding, ContentLanguage, ContentType, - Error as RainMetaError, KnownMagic, RainMetaDocumentV1Item, + types::dotrain::gui_state_v1::DotrainGuiStateV1, + types::raindex_signed_context_oracle::RaindexSignedContextOracleV1, ContentEncoding, + ContentLanguage, ContentType, Error as RainMetaError, KnownMagic, RainMetaDocumentV1Item, }; use rain_metadata_bindings::MetaBoard::emitMetaCall; use rain_orderbook_app_settings::deployment::DeploymentCfg; @@ -130,6 +131,21 @@ impl AddOrderArgs { }); } + // If the order has an oracle URL, add a RaindexSignedContextOracleV1 meta item + let additional_meta = { + let mut meta = additional_meta.unwrap_or_default(); + if let Some(ref oracle_url) = deployment.order.oracle_url { + let oracle = RaindexSignedContextOracleV1::parse(oracle_url) + .map_err(AddOrderArgsError::RainMetaError)?; + meta.push(oracle.to_meta_item()); + } + if meta.is_empty() { + None + } else { + Some(meta) + } + }; + Ok(AddOrderArgs { dotrain: dotrain.to_string(), inputs, @@ -686,6 +702,7 @@ price: 2e18; network: network_arc.clone(), deployer: None, orderbook: None, + oracle_url: None, }; let deployment = DeploymentCfg { document: Arc::new(RwLock::new(StrictYaml::String("".to_string()))), @@ -809,6 +826,7 @@ _ _: 0 0; network: network_arc.clone(), deployer: None, orderbook: None, + oracle_url: None, }; let deployment = DeploymentCfg { document: Arc::new(RwLock::new(StrictYaml::String("".to_string()))), @@ -975,6 +993,7 @@ _ _: 0 0; network: network_arc.clone(), deployer: None, orderbook: None, + oracle_url: None, }; let deployment = DeploymentCfg { document: Arc::new(RwLock::new(StrictYaml::String("".to_string()))), @@ -1319,6 +1338,7 @@ _ _: 16 52; network: network_arc.clone(), deployer: None, orderbook: None, + oracle_url: None, }; DeploymentCfg { document: default_document(), diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 6cdf43ce5f..38b5b3d70f 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -7,6 +7,7 @@ pub mod erc20; pub mod fuzz; pub mod local_db; pub mod meta; +pub mod oracle; pub mod parsed_meta; pub mod raindex_client; pub mod rainlang; diff --git a/crates/common/src/oracle.rs b/crates/common/src/oracle.rs new file mode 100644 index 0000000000..06930ae80a --- /dev/null +++ b/crates/common/src/oracle.rs @@ -0,0 +1,3 @@ +// Re-export oracle types and functions from the quote crate. +// This maintains backward compatibility for code in common that uses oracle functionality. +pub use rain_orderbook_quote::oracle::*; \ No newline at end of file diff --git a/crates/common/src/raindex_client/orders.rs b/crates/common/src/raindex_client/orders.rs index 80f91891cd..eb3b1a3fe8 100644 --- a/crates/common/src/raindex_client/orders.rs +++ b/crates/common/src/raindex_client/orders.rs @@ -317,6 +317,16 @@ impl RaindexOrder { _ => None, }) } + + /// Extract oracle URL from raw meta bytes. + /// + /// Takes raw meta bytes, CBOR decodes them as RainMetaDocumentV1Items, + /// and finds RaindexSignedContextOracleV1 by magic number. + /// Returns the oracle URL if found, None otherwise. + #[wasm_bindgen(js_name = extractOracleUrl)] + pub fn extract_oracle_url_wasm(meta_bytes: &[u8]) -> Option { + RaindexOrder::extract_oracle_url(meta_bytes) + } #[wasm_bindgen(getter)] pub fn transaction(&self) -> Option { self.transaction.clone() @@ -400,6 +410,19 @@ impl RaindexOrder { _ => None, }) } + + /// Extract oracle URL from raw meta bytes. + /// + /// Takes raw meta bytes, CBOR decodes them as RainMetaDocumentV1Items, + /// and finds RaindexSignedContextOracleV1 by magic number. + /// Returns the oracle URL if found, None otherwise. + pub fn extract_oracle_url(meta_bytes: &[u8]) -> Option { + use rain_metadata::{types::raindex_signed_context_oracle::RaindexSignedContextOracleV1, RainMetaDocumentV1Item}; + + let items = RainMetaDocumentV1Item::cbor_decode(meta_bytes).ok()?; + let oracle = RaindexSignedContextOracleV1::find_in_items(&items).ok()??; + Some(oracle.url().to_string()) + } pub fn transaction(&self) -> Option { self.transaction.clone() } @@ -718,6 +741,7 @@ impl RaindexOrder { &rpc_urls, Some(block_number), sell_token, + self.oracle_url(), ) .await } diff --git a/crates/common/src/raindex_client/take_orders/single.rs b/crates/common/src/raindex_client/take_orders/single.rs index fa0617e50e..c87b0731ba 100644 --- a/crates/common/src/raindex_client/take_orders/single.rs +++ b/crates/common/src/raindex_client/take_orders/single.rs @@ -54,6 +54,7 @@ pub fn build_candidate_from_quote( output_io_index, max_output: data.max_output, ratio: data.ratio, + signed_context: vec![], })) } @@ -102,6 +103,7 @@ pub fn estimate_take_order( )) } +#[allow(clippy::too_many_arguments)] pub async fn execute_single_take( candidate: TakeOrderCandidate, mode: ParsedTakeOrdersMode, @@ -110,7 +112,25 @@ pub async fn execute_single_take( rpc_urls: &[Url], block_number: Option, sell_token: Address, + oracle_url: Option, ) -> Result { + // Fetch signed context from oracle if URL provided + let mut candidate = candidate; + if let Some(url) = oracle_url { + let body = crate::oracle::encode_oracle_body( + &candidate.order, + candidate.input_io_index, + candidate.output_io_index, + taker, + ); + match crate::oracle::fetch_signed_context(&url, body).await { + Ok(ctx) => candidate.signed_context = vec![ctx], + Err(e) => { + tracing::warn!("Failed to fetch oracle data from {}: {}", url, e); + } + } + } + let zero = Float::zero()?; if candidate.ratio.gt(price_cap)? { diff --git a/crates/common/src/raindex_client/take_orders/single_tests.rs b/crates/common/src/raindex_client/take_orders/single_tests.rs index 50f9346017..95fb962aa2 100644 --- a/crates/common/src/raindex_client/take_orders/single_tests.rs +++ b/crates/common/src/raindex_client/take_orders/single_tests.rs @@ -107,6 +107,7 @@ async fn test_single_order_take_happy_path_buy_up_to() { fund_and_approve_taker( &setup, setup.token1, + None, taker, setup.orderbook, U256::from(10).pow(U256::from(22)), @@ -139,6 +140,7 @@ async fn test_single_order_take_happy_path_buy_up_to() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed with BuyUpTo mode"); @@ -215,6 +217,7 @@ async fn test_single_order_take_happy_path_buy_exact() { fund_and_approve_taker( &setup, setup.token1, + None, taker, setup.orderbook, U256::from(10).pow(U256::from(22)), @@ -248,6 +251,7 @@ async fn test_single_order_take_happy_path_buy_exact() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed with BuyExact mode"); @@ -318,6 +322,7 @@ async fn test_single_order_take_happy_path_spend_up_to() { fund_and_approve_taker( &setup, setup.token1, + None, taker, setup.orderbook, U256::from(10).pow(U256::from(22)), @@ -350,6 +355,7 @@ async fn test_single_order_take_happy_path_spend_up_to() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed with SpendUpTo mode"); @@ -544,6 +550,7 @@ async fn test_single_order_take_buy_exact_insufficient_liquidity() { fund_and_approve_taker( &setup, setup.token1, + None, taker, setup.orderbook, U256::from(10).pow(U256::from(22)), @@ -576,6 +583,7 @@ async fn test_single_order_take_buy_exact_insufficient_liquidity() { &rpc_urls, None, setup.token1, + None, ) .await; @@ -635,6 +643,7 @@ async fn test_single_order_take_price_exceeds_cap() { fund_and_approve_taker( &setup, setup.token1, + None, taker, setup.orderbook, U256::from(10).pow(U256::from(22)), @@ -667,6 +676,7 @@ async fn test_single_order_take_price_exceeds_cap() { &rpc_urls, None, setup.token1, + None, ) .await; @@ -799,6 +809,7 @@ async fn test_single_order_take_preflight_insufficient_balance() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed with approval result"); @@ -904,6 +915,7 @@ async fn test_single_order_take_preflight_insufficient_allowance() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed with approval result"); @@ -1010,6 +1022,7 @@ async fn test_single_order_take_approval_then_ready_flow() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed with approval result"); @@ -1056,6 +1069,7 @@ async fn test_single_order_take_approval_then_ready_flow() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed with ready result after approval"); @@ -1134,6 +1148,7 @@ async fn test_single_order_take_calldata_encoding_buy_mode() { fund_and_approve_taker( &setup, setup.token1, + None, taker, setup.orderbook, U256::from(10).pow(U256::from(22)), @@ -1168,6 +1183,7 @@ async fn test_single_order_take_calldata_encoding_buy_mode() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed"); @@ -1243,6 +1259,7 @@ async fn test_single_order_take_expected_spend_calculation() { fund_and_approve_taker( &setup, setup.token1, + None, taker, setup.orderbook, U256::from(10).pow(U256::from(22)), @@ -1275,6 +1292,7 @@ async fn test_single_order_take_expected_spend_calculation() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed"); @@ -1353,6 +1371,7 @@ async fn test_single_order_take_spend_exact_mode() { fund_and_approve_taker( &setup, setup.token1, + None, taker, setup.orderbook, U256::from(10).pow(U256::from(22)), @@ -1386,6 +1405,7 @@ async fn test_single_order_take_spend_exact_mode() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed with SpendExact mode"); @@ -1584,6 +1604,7 @@ async fn test_single_order_take_spend_exact_insufficient_liquidity() { fund_and_approve_taker( &setup, setup.token1, + None, taker, setup.orderbook, U256::from(10).pow(U256::from(22)), @@ -1616,6 +1637,7 @@ async fn test_single_order_take_spend_exact_insufficient_liquidity() { &rpc_urls, None, setup.token1, + None, ) .await; @@ -1675,6 +1697,7 @@ async fn test_single_order_take_calldata_encoding_spend_mode() { fund_and_approve_taker( &setup, setup.token1, + None, taker, setup.orderbook, U256::from(10).pow(U256::from(22)), @@ -1709,6 +1732,7 @@ async fn test_single_order_take_calldata_encoding_spend_mode() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed"); @@ -1793,6 +1817,7 @@ async fn test_single_order_take_expected_receive_calculation() { fund_and_approve_taker( &setup, setup.token1, + None, taker, setup.orderbook, U256::from(10).pow(U256::from(22)), @@ -1825,6 +1850,7 @@ async fn test_single_order_take_expected_receive_calculation() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed"); diff --git a/crates/common/src/take_orders/candidates.rs b/crates/common/src/take_orders/candidates.rs index 62796bc6d0..82a5f56fa9 100644 --- a/crates/common/src/take_orders/candidates.rs +++ b/crates/common/src/take_orders/candidates.rs @@ -2,8 +2,9 @@ use crate::raindex_client::order_quotes::{get_order_quotes_batch, RaindexOrderQu use crate::raindex_client::orders::RaindexOrder; use crate::raindex_client::RaindexError; use alloy::primitives::Address; +use futures::StreamExt; use rain_math_float::Float; -use rain_orderbook_bindings::IOrderBookV6::OrderV4; +use rain_orderbook_bindings::IOrderBookV6::{OrderV4, SignedContextV1}; #[cfg(target_family = "wasm")] use std::str::FromStr; @@ -38,6 +39,8 @@ pub struct TakeOrderCandidate { pub output_io_index: u32, pub max_output: Float, pub ratio: Float, + /// Signed context data fetched from the order's oracle endpoint (if any). + pub signed_context: Vec, } fn get_orderbook_address(order: &RaindexOrder) -> Result { @@ -51,37 +54,96 @@ fn get_orderbook_address(order: &RaindexOrder) -> Result } } -fn build_candidates_for_order( - order: &RaindexOrder, - quotes: Vec, - input_token: Address, - output_token: Address, -) -> Result, RaindexError> { - let order_v4: OrderV4 = order.try_into()?; - let orderbook = get_orderbook_address(order)?; - - quotes - .iter() - .map(|quote| try_build_candidate(orderbook, &order_v4, quote, input_token, output_token)) - .collect::, _>>() - .map(|opts| opts.into_iter().flatten().collect()) -} - pub async fn build_take_order_candidates_for_pair( orders: &[RaindexOrder], input_token: Address, output_token: Address, block_number: Option, - chunk_size: Option, + gas: Option, ) -> Result, RaindexError> { - let all_quotes = get_order_quotes_batch(orders, block_number, chunk_size).await?; - - orders - .iter() - .zip(all_quotes) - .map(|(order, quotes)| build_candidates_for_order(order, quotes, input_token, output_token)) - .collect::, _>>() - .map(|vecs| vecs.into_iter().flatten().collect()) + let gas_string = gas.map(|g| g.to_string()); + + // Fetch quotes for each order (oracle context fetched per-pair inside get_quotes) + let results: Vec, RaindexError>> = + futures::stream::iter(orders.iter().map(|order| { + let gas_string = gas_string.clone(); + async move { order.get_quotes(block_number, gas_string).await } + })) + .buffered(10) + .collect() + .await; + + // Build candidates — oracle context for take-order will be fetched per-pair + let mut all_candidates = vec![]; + for (order, quotes_result) in orders.iter().zip(results) { + let quotes = quotes_result?; + let order_v4: OrderV4 = order.try_into()?; + let orderbook = get_orderbook_address(order)?; + let oracle_url = { + #[cfg(target_family = "wasm")] + { + order.oracle_url() + } + #[cfg(not(target_family = "wasm"))] + { + order.oracle_url() + } + }; + + for quote in "es { + let signed_context = match &oracle_url { + Some(url) => { + fetch_oracle_for_pair( + url, + &order_v4, + quote.pair.input_index, + quote.pair.output_index, + Address::ZERO, // counterparty unknown at candidate building time + ) + .await + } + None => vec![], + }; + + if let Some(candidate) = try_build_candidate( + orderbook, + &order_v4, + quote, + input_token, + output_token, + signed_context, + )? { + all_candidates.push(candidate); + } + } + } + + Ok(all_candidates) +} + +/// Fetch signed context from an order's oracle endpoint for a specific IO pair. +/// Returns empty vec if no oracle URL or if fetch fails (best-effort). +async fn fetch_oracle_for_pair( + oracle_url: &str, + order: &OrderV4, + input_io_index: u32, + output_io_index: u32, + counterparty: Address, +) -> Vec { + let body = + crate::oracle::encode_oracle_body(order, input_io_index, output_io_index, counterparty); + match crate::oracle::fetch_signed_context(oracle_url, body).await { + Ok(ctx) => vec![ctx], + Err(e) => { + tracing::warn!( + "Failed to fetch oracle for pair ({}, {}): {}", + input_io_index, + output_io_index, + e + ); + vec![] + } + } } fn try_build_candidate( @@ -90,6 +152,7 @@ fn try_build_candidate( quote: &RaindexOrderQuote, input_token: Address, output_token: Address, + signed_context: Vec, ) -> Result, RaindexError> { let data = match (quote.success, "e.data) { (true, Some(d)) => d, @@ -124,6 +187,7 @@ fn try_build_candidate( output_io_index, max_output: data.max_output, ratio: data.ratio, + signed_context, })) } @@ -249,7 +313,7 @@ mod tests { let f1 = Float::parse("1".to_string()).unwrap(); let quote = make_quote(0, 0, Some(make_quote_value(f1, f1, f1)), true); - let result = try_build_candidate(orderbook, &order, "e, token_b, token_a).unwrap(); + let result = try_build_candidate(orderbook, &order, "e, token_b, token_a, vec![]).unwrap(); assert!(result.is_none()); } @@ -265,7 +329,7 @@ mod tests { let f1 = Float::parse("1".to_string()).unwrap(); let quote = make_quote(0, 0, Some(make_quote_value(zero, zero, f1)), true); - let result = try_build_candidate(orderbook, &order, "e, token_a, token_b).unwrap(); + let result = try_build_candidate(orderbook, &order, "e, token_a, token_b, vec![]).unwrap(); assert!(result.is_none()); } @@ -281,7 +345,7 @@ mod tests { let f2 = Float::parse("2".to_string()).unwrap(); let quote = make_quote(0, 0, Some(make_quote_value(f2, f1, f1)), true); - let result = try_build_candidate(orderbook, &order, "e, token_a, token_b).unwrap(); + let result = try_build_candidate(orderbook, &order, "e, token_a, token_b, vec![]).unwrap(); assert!(result.is_some()); let candidate = result.unwrap(); @@ -300,7 +364,7 @@ mod tests { let order = make_basic_order(token_a, token_b); let quote = make_quote(0, 0, None, false); - let result = try_build_candidate(orderbook, &order, "e, token_a, token_b); + let result = try_build_candidate(orderbook, &order, "e, token_a, token_b, vec![]); assert!( result.is_ok(), @@ -324,7 +388,7 @@ mod tests { let quote_bad_input_index = make_quote(99, 0, Some(make_quote_value(f1, f1, f1)), true); let result = - try_build_candidate(orderbook, &order, "e_bad_input_index, token_a, token_b); + try_build_candidate(orderbook, &order, "e_bad_input_index, token_a, token_b, vec![]); assert!( result.is_ok(), "Out-of-bounds input index must not cause an error" @@ -336,7 +400,7 @@ mod tests { let quote_bad_output_index = make_quote(0, 99, Some(make_quote_value(f1, f1, f1)), true); let result = - try_build_candidate(orderbook, &order, "e_bad_output_index, token_a, token_b); + try_build_candidate(orderbook, &order, "e_bad_output_index, token_a, token_b, vec![]); assert!( result.is_ok(), "Out-of-bounds output index must not cause an error" diff --git a/crates/common/src/take_orders/config.rs b/crates/common/src/take_orders/config.rs index 5e6ec8a371..02540a5a0a 100644 --- a/crates/common/src/take_orders/config.rs +++ b/crates/common/src/take_orders/config.rs @@ -93,7 +93,7 @@ pub fn build_take_orders_config_from_simulation( order: leg.candidate.order.clone(), inputIOIndex: U256::from(leg.candidate.input_io_index), outputIOIndex: U256::from(leg.candidate.output_io_index), - signedContext: vec![] as Vec, + signedContext: leg.candidate.signed_context.clone(), }) .collect(); diff --git a/crates/common/src/test_helpers.rs b/crates/common/src/test_helpers.rs index 5222a4829e..90b5872c88 100644 --- a/crates/common/src/test_helpers.rs +++ b/crates/common/src/test_helpers.rs @@ -608,6 +608,7 @@ pub mod candidates { output_io_index: 0, max_output, ratio, + signed_context: vec![], } } diff --git a/crates/quote/Cargo.toml b/crates/quote/Cargo.toml index 0a586666a1..0ebe4fa4dd 100644 --- a/crates/quote/Cargo.toml +++ b/crates/quote/Cargo.toml @@ -13,8 +13,10 @@ crate-type = ["rlib"] [dependencies] rain-math-float.workspace = true +rain-metadata = { workspace = true } rain_orderbook_bindings = { workspace = true } rain_orderbook_subgraph_client = { workspace = true } +futures = { workspace = true } rain-error-decoding = { workspace = true } alloy = { workspace = true, features = ["sol-types"] } alloy-ethers-typecast = { workspace = true } diff --git a/crates/quote/src/lib.rs b/crates/quote/src/lib.rs index ad553865ec..8a853cb6d1 100644 --- a/crates/quote/src/lib.rs +++ b/crates/quote/src/lib.rs @@ -6,6 +6,7 @@ mod quote; mod quote_debug; pub mod rpc; +pub mod oracle; mod order_quotes; pub use order_quotes::*; diff --git a/crates/quote/src/oracle.rs b/crates/quote/src/oracle.rs new file mode 100644 index 0000000000..421f604afd --- /dev/null +++ b/crates/quote/src/oracle.rs @@ -0,0 +1,257 @@ +use alloy::primitives::{Address, Bytes, FixedBytes, U256}; +use alloy::sol_types::SolValue; +use rain_orderbook_bindings::IOrderBookV6::{OrderV4, SignedContextV1}; +use rain_orderbook_subgraph_client::types::common::SgOrder; +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +/// Error types for oracle fetching +#[derive(Debug, thiserror::Error)] +pub enum OracleError { + #[error("HTTP request failed: {0}")] + RequestFailed(#[from] reqwest::Error), + + #[error("Invalid oracle response: {0}")] + InvalidResponse(String), + + #[error("Invalid URL: {0}")] + InvalidUrl(String), +} + +/// JSON response format from an oracle endpoint. +/// Maps directly to `SignedContextV1` in the orderbook contract. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OracleResponse { + /// The signer address (EIP-191 signer of the context data) + pub signer: Address, + /// The signed context data as bytes32[] values + pub context: Vec>, + /// The EIP-191 signature over keccak256(abi.encodePacked(context)) + pub signature: Bytes, +} + +impl From for SignedContextV1 { + fn from(resp: OracleResponse) -> Self { + SignedContextV1 { + signer: resp.signer, + context: resp.context, + signature: resp.signature, + } + } +} + +/// Encode the POST body for a single oracle request. +/// +/// The body is `abi.encode(OrderV4, uint256 inputIOIndex, uint256 outputIOIndex, address counterparty)`. +pub fn encode_oracle_body( + order: &OrderV4, + input_io_index: u32, + output_io_index: u32, + counterparty: Address, +) -> Vec { + ( + order.clone(), + U256::from(input_io_index), + U256::from(output_io_index), + counterparty, + ) + .abi_encode() +} + +/// Encode the POST body for a batch oracle request. +/// +/// The body is `abi.encode((OrderV4, uint256 inputIOIndex, uint256 outputIOIndex, address counterparty)[])`. +pub fn encode_oracle_body_batch( + requests: Vec<(&OrderV4, u32, u32, Address)>, +) -> Vec { + let tuples: Vec<_> = requests + .into_iter() + .map(|(order, input_io_index, output_io_index, counterparty)| { + ( + order.clone(), + U256::from(input_io_index), + U256::from(output_io_index), + counterparty, + ) + }) + .collect(); + + tuples.abi_encode() +} + +/// Fetch signed context from an oracle endpoint via POST (single request). +/// +/// The endpoint receives an ABI-encoded body containing the order details +/// that will be used for calculateOrderIO: +/// `abi.encode(OrderV4, uint256 inputIOIndex, uint256 outputIOIndex, address counterparty)` +/// +/// The endpoint must respond with a JSON body matching a single `OracleResponse`. +/// +/// NOTE: This is a legacy function. The batch format is preferred. +pub async fn fetch_signed_context( + url: &str, + body: Vec, +) -> Result { + let builder = Client::builder(); + #[cfg(not(target_family = "wasm"))] + let builder = builder.timeout(std::time::Duration::from_secs(10)); + let client = builder.build()?; + + // For single requests, we still expect a JSON array response but with one item + let response: Vec = client + .post(url) + .header("Content-Type", "application/octet-stream") + .body(body) + .send() + .await? + .error_for_status()? + .json() + .await?; + + if response.len() != 1 { + return Err(OracleError::InvalidResponse( + format!("Expected 1 response, got {}", response.len()) + )); + } + + Ok(response.into_iter().next().unwrap().into()) +} + +/// Fetch signed context from an oracle endpoint via POST (batch request). +/// +/// The endpoint receives an ABI-encoded body containing an array of order details: +/// `abi.encode((OrderV4, uint256 inputIOIndex, uint256 outputIOIndex, address counterparty)[])` +/// +/// The endpoint must respond with a JSON array of `OracleResponse` objects. +/// The response array length must match the request array length. +pub async fn fetch_signed_context_batch( + url: &str, + body: Vec, +) -> Result, OracleError> { + let builder = Client::builder(); + #[cfg(not(target_family = "wasm"))] + let builder = builder.timeout(std::time::Duration::from_secs(10)); + let client = builder.build()?; + + let response: Vec = client + .post(url) + .header("Content-Type", "application/octet-stream") + .body(body) + .send() + .await? + .error_for_status()? + .json() + .await?; + + Ok(response.into_iter().map(|resp| resp.into()).collect()) +} + +/// Extract the oracle URL from an SgOrder's meta, if present. +/// +/// Parses the meta bytes and looks for a `RaindexSignedContextOracleV1` entry. +/// Returns `None` if meta is absent, unparseable, or doesn't contain an oracle entry. +pub fn extract_oracle_url(order: &SgOrder) -> Option { + use rain_metadata::types::raindex_signed_context_oracle::RaindexSignedContextOracleV1; + use rain_metadata::RainMetaDocumentV1Item; + + let meta = order.meta.as_ref()?; + let decoded = alloy::hex::decode(&meta.0).ok()?; + let items = RainMetaDocumentV1Item::cbor_decode(&decoded).ok()?; + let oracle = RaindexSignedContextOracleV1::find_in_items(&items).ok()??; + Some(oracle.url().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::{address, FixedBytes}; + use rain_orderbook_bindings::IOrderBookV6::{EvaluableV4, IOV2, OrderV4}; + + #[test] + fn test_oracle_response_to_signed_context() { + let ctx_val = FixedBytes::<32>::from([0x2a; 32]); + let response = OracleResponse { + signer: address!("0x1234567890123456789012345678901234567890"), + context: vec![ctx_val], + signature: Bytes::from(vec![0xaa, 0xbb, 0xcc]), + }; + + let signed: SignedContextV1 = response.into(); + assert_eq!( + signed.signer, + address!("0x1234567890123456789012345678901234567890") + ); + assert_eq!(signed.context.len(), 1); + assert_eq!(signed.context[0], ctx_val); + assert_eq!(signed.signature, Bytes::from(vec![0xaa, 0xbb, 0xcc])); + } + + #[test] + fn test_encode_oracle_body_single() { + let order = create_test_order(); + let body = encode_oracle_body(&order, 1, 2, address!("0x1111111111111111111111111111111111111111")); + assert!(!body.is_empty()); + } + + #[test] + fn test_encode_oracle_body_batch() { + let order1 = create_test_order(); + let order2 = create_test_order(); + + let requests = vec![ + (&order1, 1, 2, address!("0x1111111111111111111111111111111111111111")), + (&order2, 3, 4, address!("0x2222222222222222222222222222222222222222")), + ]; + + let body = encode_oracle_body_batch(requests); + assert!(!body.is_empty()); + + // Batch encoding should be different from single encoding + let single_body = encode_oracle_body(&order1, 1, 2, address!("0x1111111111111111111111111111111111111111")); + assert_ne!(body, single_body); + } + + #[tokio::test] + async fn test_fetch_signed_context_invalid_url() { + let result = fetch_signed_context("not-a-url", vec![]).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_fetch_signed_context_unreachable() { + let result = fetch_signed_context("http://127.0.0.1:1/oracle", vec![]).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_fetch_signed_context_batch_invalid_url() { + let result = fetch_signed_context_batch("not-a-url", vec![]).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_fetch_signed_context_batch_unreachable() { + let result = fetch_signed_context_batch("http://127.0.0.1:1/oracle", vec![]).await; + assert!(result.is_err()); + } + + fn create_test_order() -> OrderV4 { + OrderV4 { + owner: address!("0x0000000000000000000000000000000000000000"), + evaluable: EvaluableV4 { + interpreter: address!("0x0000000000000000000000000000000000000000"), + store: address!("0x0000000000000000000000000000000000000000"), + bytecode: Bytes::new(), + }, + validInputs: vec![IOV2 { + token: address!("0x0000000000000000000000000000000000000000"), + vaultId: FixedBytes::<32>::ZERO, + }], + validOutputs: vec![IOV2 { + token: address!("0x0000000000000000000000000000000000000000"), + vaultId: FixedBytes::<32>::ZERO, + }], + nonce: FixedBytes::<32>::ZERO, + } + } +} \ No newline at end of file diff --git a/crates/quote/src/order_quotes.rs b/crates/quote/src/order_quotes.rs index 8aa70534f4..77621c22d8 100644 --- a/crates/quote/src/order_quotes.rs +++ b/crates/quote/src/order_quotes.rs @@ -38,6 +38,10 @@ pub struct Pair { #[cfg(target_family = "wasm")] impl_wasm_traits!(Pair); +/// Get order quotes, automatically fetching signed oracle context from order meta. +/// +/// For each order, if the meta contains a `RaindexSignedContextOracleV1` entry, +/// the oracle URL is extracted and signed context is fetched per IO pair via POST. pub async fn get_order_quotes( orders: Vec, block_number: Option, @@ -59,6 +63,7 @@ pub async fn get_order_quotes( for order in &orders { let order_struct: OrderV4 = order.clone().try_into()?; let orderbook = Address::from_str(&order.orderbook.id.0)?; + let oracle_url = crate::oracle::extract_oracle_url(order); for (input_index, input) in order_struct.validInputs.iter().enumerate() { for (output_index, output) in order_struct.validOutputs.iter().enumerate() { @@ -92,6 +97,34 @@ pub async fn get_order_quotes( .unwrap_or("UNKNOWN".to_string()) ); + // Fetch signed oracle context for this pair if oracle URL is present + let signed_context = if let Some(ref url) = oracle_url { + if input.token != output.token { + let body = crate::oracle::encode_oracle_body( + &order_struct, + input_index as u32, + output_index as u32, + Address::ZERO, // counterparty unknown at quote time + ); + match crate::oracle::fetch_signed_context(url, body).await { + Ok(ctx) => vec![ctx], + Err(e) => { + tracing::warn!( + "Failed to fetch oracle for pair ({}, {}): {}", + input_index, + output_index, + e + ); + vec![] + } + } + } else { + vec![] + } + } else { + vec![] + }; + all_pairs.push(Pair { pair_name, input_index: input_index as u32, @@ -103,7 +136,7 @@ pub async fn get_order_quotes( order: order_struct.clone(), inputIOIndex: U256::from(input_index), outputIOIndex: U256::from(output_index), - signedContext: vec![], + signedContext: signed_context, }, }); } diff --git a/crates/settings/src/order.rs b/crates/settings/src/order.rs index 9c0d896501..dd304d79c3 100644 --- a/crates/settings/src/order.rs +++ b/crates/settings/src/order.rs @@ -9,7 +9,7 @@ use std::{ use strict_yaml_rust::{strict_yaml::Hash, StrictYaml}; use thiserror::Error; -const ALLOWED_ORDER_KEYS: [&str; 4] = ["deployer", "inputs", "orderbook", "outputs"]; +const ALLOWED_ORDER_KEYS: [&str; 5] = ["deployer", "inputs", "oracle-url", "orderbook", "outputs"]; const ALLOWED_ORDER_IO_KEYS: [&str; 2] = ["token", "vault-id"]; use wasm_bindgen_utils::{impl_wasm_traits, prelude::*}; use yaml::{ @@ -56,6 +56,9 @@ pub struct OrderCfg { pub deployer: Option>, #[cfg_attr(target_family = "wasm", tsify(optional))] pub orderbook: Option>, + #[cfg_attr(target_family = "wasm", tsify(optional))] + #[serde(rename = "oracle-url")] + pub oracle_url: Option, } #[cfg(target_family = "wasm")] impl_wasm_traits!(OrderCfg); @@ -640,6 +643,8 @@ impl YamlParsableHash for OrderCfg { None => None, }; + let oracle_url = optional_string(order_yaml, "oracle-url"); + let inputs = require_vec( order_yaml, "inputs", @@ -820,6 +825,7 @@ impl YamlParsableHash for OrderCfg { )?, deployer, orderbook, + oracle_url, }; if orders.contains_key(&order_key) { @@ -929,6 +935,7 @@ impl Default for OrderCfg { network: Arc::new(NetworkCfg::default()), deployer: None, orderbook: None, + oracle_url: None, } } } @@ -941,6 +948,7 @@ impl PartialEq for OrderCfg { && self.network == other.network && self.deployer == other.deployer && self.orderbook == other.orderbook + && self.oracle_url == other.oracle_url } } diff --git a/packages/ui-components/src/lib/components/detail/OrderDetail.svelte b/packages/ui-components/src/lib/components/detail/OrderDetail.svelte index 6693b87d60..19783334cb 100644 --- a/packages/ui-components/src/lib/components/detail/OrderDetail.svelte +++ b/packages/ui-components/src/lib/components/detail/OrderDetail.svelte @@ -200,6 +200,30 @@ + {#if data.oracleUrl} + + +
+ Oracle + This order uses a signed context oracle for external data (e.g. price feeds). + Quotes include oracle data automatically. +
+
+ + + {data.oracleUrl} + + +
+ {/if} + {#each vaultTypesMap as { key, type, getter }} {@const filteredVaults = data.vaultsList.items.filter((vault) => vault.vaultType === type)} {@const vaultsListByType = data[getter]}