From f29d775c4c547fd4521f6d9675ae2ea067b1fc0c Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Sun, 15 Feb 2026 13:10:32 +0000 Subject: [PATCH 01/14] feat: add oracle fetch module and wire signed context into take-order flows Phase 3 of signed context oracle discovery: - New oracle.rs module with fetch_signed_context(url) and OracleResponse type - OracleResponse maps directly to SignedContextV1 (signer, context as bytes32[], signature) - Added signed_context field to TakeOrderCandidate - Wired oracle fetching into: - build_take_order_candidates_for_pair (batch flow, concurrent fetch) - execute_single_take (single take flow, oracle_url param) - build_take_orders_config_from_simulation (passes through to TakeOrderConfigV4) - Oracle fetch is best-effort: failures log a warning and use empty signed context - 3 oracle tests + 9 parsed_meta tests passing --- crates/common/src/lib.rs | 1 + crates/common/src/oracle.rs | 115 ++++++++++++++++++ crates/common/src/raindex_client/orders.rs | 1 + .../src/raindex_client/take_orders/single.rs | 13 ++ .../take_orders/single_tests.rs | 15 +++ crates/common/src/take_orders/candidates.rs | 75 ++++++++++-- crates/common/src/take_orders/config.rs | 4 +- crates/common/src/test_helpers.rs | 1 + 8 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 crates/common/src/oracle.rs 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..5642eecd4b --- /dev/null +++ b/crates/common/src/oracle.rs @@ -0,0 +1,115 @@ +use alloy::primitives::{Address, Bytes, FixedBytes}; +use rain_orderbook_bindings::IOrderBookV6::SignedContextV1; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// 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, + } + } +} + +const DEFAULT_TIMEOUT_SECS: u64 = 10; + +/// Fetch signed context from an oracle endpoint. +/// +/// The endpoint must respond to a GET request with a JSON body matching +/// `OracleResponse` (signer, context, signature). +pub async fn fetch_signed_context(url: &str) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) + .build()?; + + let response: OracleResponse = client + .get(url) + .send() + .await? + .error_for_status()? + .json() + .await?; + + Ok(response.into()) +} + +/// Fetch signed contexts for multiple oracle URLs concurrently. +/// +/// Returns a vec of results - one per URL. Failed fetches return errors +/// rather than failing the entire batch, so callers can decide how to handle +/// partial failures. +pub async fn fetch_signed_contexts( + urls: &[String], +) -> Vec> { + let futures: Vec<_> = urls + .iter() + .map(|url| fetch_signed_context(url)) + .collect(); + + futures::future::join_all(futures).await +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::{address, FixedBytes}; + + #[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])); + } + + #[tokio::test] + async fn test_fetch_signed_context_invalid_url() { + let result = fetch_signed_context("not-a-url").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").await; + assert!(result.is_err()); + } +} diff --git a/crates/common/src/raindex_client/orders.rs b/crates/common/src/raindex_client/orders.rs index 082563b168..9fb8c7ff4f 100644 --- a/crates/common/src/raindex_client/orders.rs +++ b/crates/common/src/raindex_client/orders.rs @@ -676,6 +676,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 18515d229f..4389bf64e3 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![], })) } @@ -110,7 +111,19 @@ 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 { + match crate::oracle::fetch_signed_context(&url).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 b3ace80bc0..a917b1867b 100644 --- a/crates/common/src/raindex_client/take_orders/single_tests.rs +++ b/crates/common/src/raindex_client/take_orders/single_tests.rs @@ -139,6 +139,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"); @@ -248,6 +249,7 @@ async fn test_single_order_take_happy_path_buy_exact() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed with BuyExact mode"); @@ -350,6 +352,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"); @@ -576,6 +579,7 @@ async fn test_single_order_take_buy_exact_insufficient_liquidity() { &rpc_urls, None, setup.token1, + None, ) .await; @@ -667,6 +671,7 @@ async fn test_single_order_take_price_exceeds_cap() { &rpc_urls, None, setup.token1, + None, ) .await; @@ -799,6 +804,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 +910,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 +1017,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 +1064,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"); @@ -1168,6 +1177,7 @@ async fn test_single_order_take_calldata_encoding_buy_mode() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed"); @@ -1275,6 +1285,7 @@ async fn test_single_order_take_expected_spend_calculation() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed"); @@ -1386,6 +1397,7 @@ async fn test_single_order_take_spend_exact_mode() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed with SpendExact mode"); @@ -1616,6 +1628,7 @@ async fn test_single_order_take_spend_exact_insufficient_liquidity() { &rpc_urls, None, setup.token1, + None, ) .await; @@ -1709,6 +1722,7 @@ async fn test_single_order_take_calldata_encoding_spend_mode() { &rpc_urls, None, setup.token1, + None, ) .await .expect("Should succeed"); @@ -1825,6 +1839,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 e4f62095c5..5f2a8ac8bb 100644 --- a/crates/common/src/take_orders/candidates.rs +++ b/crates/common/src/take_orders/candidates.rs @@ -4,7 +4,7 @@ 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; @@ -41,6 +41,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 { @@ -59,13 +61,23 @@ fn build_candidates_for_order( quotes: Vec, input_token: Address, output_token: Address, + signed_context: Vec, ) -> 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)) + .map(|quote| { + try_build_candidate( + orderbook, + &order_v4, + quote, + input_token, + output_token, + signed_context.clone(), + ) + }) .collect::, _>>() .map(|opts| opts.into_iter().flatten().collect()) } @@ -79,10 +91,15 @@ pub async fn build_take_order_candidates_for_pair( ) -> Result, RaindexError> { let gas_string = gas.map(|g| g.to_string()); - let quote_results: Vec> = + // Fetch quotes and oracle data concurrently for each order + let results: Vec<(Result, RaindexError>, Vec)> = futures::stream::iter(orders.iter().map(|order| { let gas_string = gas_string.clone(); - async move { order.get_quotes(block_number, gas_string).await } + async move { + let quotes = order.get_quotes(block_number, gas_string).await; + let signed_context = fetch_oracle_for_order(order).await; + (quotes, signed_context) + } })) .buffered(DEFAULT_QUOTE_CONCURRENCY) .collect() @@ -90,20 +107,51 @@ pub async fn build_take_order_candidates_for_pair( orders .iter() - .zip(quote_results) - .map(|(order, quotes_result)| { - build_candidates_for_order(order, quotes_result?, input_token, output_token) + .zip(results) + .map(|(order, (quotes_result, signed_context))| { + build_candidates_for_order( + order, + quotes_result?, + input_token, + output_token, + signed_context, + ) }) .collect::, _>>() .map(|vecs| vecs.into_iter().flatten().collect()) } +/// Fetch signed context from an order's oracle endpoint, if it has one. +/// Returns empty vec if no oracle URL or if fetch fails (best-effort). +async fn fetch_oracle_for_order(order: &RaindexOrder) -> Vec { + #[cfg(target_family = "wasm")] + let url = order.oracle_url(); + #[cfg(not(target_family = "wasm"))] + let url = order.oracle_url(); + + match url { + Some(oracle_url) => match crate::oracle::fetch_signed_context(&oracle_url).await { + Ok(ctx) => vec![ctx], + Err(e) => { + tracing::warn!( + "Failed to fetch oracle data from {}: {}", + oracle_url, + e + ); + vec![] + } + }, + None => vec![], + } +} + fn try_build_candidate( orderbook: Address, order: &OrderV4, quote: &RaindexOrderQuote, input_token: Address, output_token: Address, + signed_context: Vec, ) -> Result, RaindexError> { let data = match (quote.success, "e.data) { (true, Some(d)) => d, @@ -138,6 +186,7 @@ fn try_build_candidate( output_io_index, max_output: data.max_output, ratio: data.ratio, + signed_context, })) } @@ -263,7 +312,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()); } @@ -279,7 +328,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()); } @@ -295,7 +344,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(); @@ -314,7 +363,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(), @@ -338,7 +387,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" @@ -350,7 +399,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..f9d0783bcf 100644 --- a/crates/common/src/take_orders/config.rs +++ b/crates/common/src/take_orders/config.rs @@ -3,7 +3,7 @@ use crate::raindex_client::RaindexError; use alloy::primitives::{Bytes, U256}; use rain_math_float::Float; use rain_orderbook_bindings::IOrderBookV6::{ - SignedContextV1, TakeOrderConfigV4, TakeOrdersConfigV5, + TakeOrderConfigV4, TakeOrdersConfigV5, }; use serde::{Deserialize, Serialize}; use wasm_bindgen_utils::{impl_wasm_traits, prelude::*}; @@ -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![], } } From 7f981097722c0efe7d24d40af3a876c4d17d3595 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Sun, 15 Feb 2026 16:16:53 +0000 Subject: [PATCH 02/14] feat: wire oracle signed context into quote flow - Add get_order_quotes_with_context() to quote crate (accepts signed_context param) - RaindexOrder.get_quotes() now fetches oracle data and passes to quotes - Original get_order_quotes() unchanged (delegates with empty context) --- .../common/src/raindex_client/order_quotes.rs | 20 +++++++++++++++++-- crates/quote/src/order_quotes.rs | 16 +++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/common/src/raindex_client/order_quotes.rs b/crates/common/src/raindex_client/order_quotes.rs index 550cba56ea..e83bffd9fa 100644 --- a/crates/common/src/raindex_client/order_quotes.rs +++ b/crates/common/src/raindex_client/order_quotes.rs @@ -1,7 +1,9 @@ use super::*; use crate::raindex_client::orders::RaindexOrder; use rain_math_float::Float; -use rain_orderbook_quote::{get_order_quotes, BatchOrderQuotesResponse, OrderQuoteValue, Pair}; +use rain_orderbook_quote::{ + get_order_quotes_with_context, BatchOrderQuotesResponse, OrderQuoteValue, Pair, +}; use rain_orderbook_subgraph_client::utils::float::{F0, F1}; use std::ops::{Div, Mul}; @@ -120,11 +122,25 @@ impl RaindexOrder { ) -> Result, RaindexError> { let gas_amount = gas.map(|v| v.parse::()).transpose()?; let rpcs = self.get_rpc_urls()?; - let order_quotes = get_order_quotes( + + // Fetch signed context from oracle if this order has one + let signed_context = match self.oracle_url() { + Some(url) => match crate::oracle::fetch_signed_context(&url).await { + Ok(ctx) => vec![ctx], + Err(e) => { + tracing::warn!("Failed to fetch oracle data from {}: {}", url, e); + vec![] + } + }, + None => vec![], + }; + + let order_quotes = get_order_quotes_with_context( vec![self.clone().into_sg_order()?], block_number, rpcs.iter().map(|s| s.to_string()).collect(), gas_amount, + signed_context, ) .await?; diff --git a/crates/quote/src/order_quotes.rs b/crates/quote/src/order_quotes.rs index 29a71d69fe..db6f7dc291 100644 --- a/crates/quote/src/order_quotes.rs +++ b/crates/quote/src/order_quotes.rs @@ -5,7 +5,7 @@ use crate::{ }; use alloy::primitives::{Address, U256}; use alloy_ethers_typecast::ReadableClient; -use rain_orderbook_bindings::IOrderBookV6::{OrderV4, QuoteV2}; +use rain_orderbook_bindings::IOrderBookV6::{OrderV4, QuoteV2, SignedContextV1}; use rain_orderbook_subgraph_client::types::common::SgOrder; use serde::{Deserialize, Serialize}; use std::str::FromStr; @@ -43,6 +43,18 @@ pub async fn get_order_quotes( block_number: Option, rpcs: Vec, gas: Option, +) -> Result, Error> { + get_order_quotes_with_context(orders, block_number, rpcs, gas, vec![]).await +} + +/// Get order quotes with optional signed context data. +/// The signed_context is applied to all quote targets for all orders. +pub async fn get_order_quotes_with_context( + orders: Vec, + block_number: Option, + rpcs: Vec, + gas: Option, + signed_context: Vec, ) -> Result, Error> { let mut results: Vec = Vec::new(); @@ -95,7 +107,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.clone(), }, }; From 1609cc1eb101135dfd67ffdb6243a3b165f65d34 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Sun, 15 Feb 2026 16:59:29 +0000 Subject: [PATCH 03/14] fix: clippy lints and formatting --- crates/common/src/oracle.rs | 9 +-- .../src/raindex_client/take_orders/single.rs | 1 + crates/common/src/take_orders/candidates.rs | 63 ++++++++++++------- 3 files changed, 42 insertions(+), 31 deletions(-) diff --git a/crates/common/src/oracle.rs b/crates/common/src/oracle.rs index 5642eecd4b..3eea2479eb 100644 --- a/crates/common/src/oracle.rs +++ b/crates/common/src/oracle.rs @@ -66,13 +66,8 @@ pub async fn fetch_signed_context(url: &str) -> Result Vec> { - let futures: Vec<_> = urls - .iter() - .map(|url| fetch_signed_context(url)) - .collect(); +pub async fn fetch_signed_contexts(urls: &[String]) -> Vec> { + let futures: Vec<_> = urls.iter().map(|url| fetch_signed_context(url)).collect(); futures::future::join_all(futures).await } diff --git a/crates/common/src/raindex_client/take_orders/single.rs b/crates/common/src/raindex_client/take_orders/single.rs index 4389bf64e3..f9d152ca88 100644 --- a/crates/common/src/raindex_client/take_orders/single.rs +++ b/crates/common/src/raindex_client/take_orders/single.rs @@ -103,6 +103,7 @@ pub fn estimate_take_order( )) } +#[allow(clippy::too_many_arguments)] pub async fn execute_single_take( candidate: TakeOrderCandidate, mode: ParsedTakeOrdersMode, diff --git a/crates/common/src/take_orders/candidates.rs b/crates/common/src/take_orders/candidates.rs index 5f2a8ac8bb..9c150d4540 100644 --- a/crates/common/src/take_orders/candidates.rs +++ b/crates/common/src/take_orders/candidates.rs @@ -91,19 +91,23 @@ pub async fn build_take_order_candidates_for_pair( ) -> Result, RaindexError> { let gas_string = gas.map(|g| g.to_string()); + type QuoteWithContext = ( + Result, RaindexError>, + Vec, + ); + // Fetch quotes and oracle data concurrently for each order - let results: Vec<(Result, RaindexError>, Vec)> = - futures::stream::iter(orders.iter().map(|order| { - let gas_string = gas_string.clone(); - async move { - let quotes = order.get_quotes(block_number, gas_string).await; - let signed_context = fetch_oracle_for_order(order).await; - (quotes, signed_context) - } - })) - .buffered(DEFAULT_QUOTE_CONCURRENCY) - .collect() - .await; + let results: Vec = futures::stream::iter(orders.iter().map(|order| { + let gas_string = gas_string.clone(); + async move { + let quotes = order.get_quotes(block_number, gas_string).await; + let signed_context = fetch_oracle_for_order(order).await; + (quotes, signed_context) + } + })) + .buffered(DEFAULT_QUOTE_CONCURRENCY) + .collect() + .await; orders .iter() @@ -133,11 +137,7 @@ async fn fetch_oracle_for_order(order: &RaindexOrder) -> Vec { Some(oracle_url) => match crate::oracle::fetch_signed_context(&oracle_url).await { Ok(ctx) => vec![ctx], Err(e) => { - tracing::warn!( - "Failed to fetch oracle data from {}: {}", - oracle_url, - e - ); + tracing::warn!("Failed to fetch oracle data from {}: {}", oracle_url, e); vec![] } }, @@ -312,7 +312,8 @@ 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, vec![]).unwrap(); + let result = + try_build_candidate(orderbook, &order, "e, token_b, token_a, vec![]).unwrap(); assert!(result.is_none()); } @@ -328,7 +329,8 @@ 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, vec![]).unwrap(); + let result = + try_build_candidate(orderbook, &order, "e, token_a, token_b, vec![]).unwrap(); assert!(result.is_none()); } @@ -344,7 +346,8 @@ 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, vec![]).unwrap(); + let result = + try_build_candidate(orderbook, &order, "e, token_a, token_b, vec![]).unwrap(); assert!(result.is_some()); let candidate = result.unwrap(); @@ -386,8 +389,14 @@ mod tests { let f1 = Float::parse("1".to_string()).unwrap(); 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, vec![]); + let result = 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" @@ -398,8 +407,14 @@ 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, vec![]); + let result = 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" From c1391fed095dc0db01721a66e80873c5f2d68927 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Sun, 15 Feb 2026 18:55:33 +0000 Subject: [PATCH 04/14] fix: conditionally apply reqwest timeout for non-WASM targets reqwest::ClientBuilder::timeout() is not available on WASM targets. Use cfg(not(target_family = "wasm")) to only set it on native. --- crates/common/src/oracle.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/common/src/oracle.rs b/crates/common/src/oracle.rs index 3eea2479eb..08c0fa5805 100644 --- a/crates/common/src/oracle.rs +++ b/crates/common/src/oracle.rs @@ -2,7 +2,6 @@ use alloy::primitives::{Address, Bytes, FixedBytes}; use rain_orderbook_bindings::IOrderBookV6::SignedContextV1; use reqwest::Client; use serde::{Deserialize, Serialize}; -use std::time::Duration; /// Error types for oracle fetching #[derive(Debug, thiserror::Error)] @@ -39,16 +38,15 @@ impl From for SignedContextV1 { } } -const DEFAULT_TIMEOUT_SECS: u64 = 10; - /// Fetch signed context from an oracle endpoint. /// /// The endpoint must respond to a GET request with a JSON body matching /// `OracleResponse` (signer, context, signature). pub async fn fetch_signed_context(url: &str) -> Result { - let client = Client::builder() - .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) - .build()?; + 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: OracleResponse = client .get(url) From f7e519b450c530a0388aad7cc86b33be39e16ded Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Sun, 15 Feb 2026 19:06:13 +0000 Subject: [PATCH 05/14] feat: show oracle info on order detail page - OrderDetail: show Oracle card property with URL link when order has oracle metadata - Includes tooltip explaining signed context oracle usage - TanstackOrderQuote: show purple 'Oracle' badge next to quotes heading when oracle is active - Indicates quotes include signed context data from oracle - Both use the oracleUrl getter exposed via WASM bindings on RaindexOrder --- .../lib/components/detail/OrderDetail.svelte | 24 +++++++++++++++++++ .../detail/TanstackOrderQuote.svelte | 24 +++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/ui-components/src/lib/components/detail/OrderDetail.svelte b/packages/ui-components/src/lib/components/detail/OrderDetail.svelte index 6693b87d60..015107b497 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]} diff --git a/packages/ui-components/src/lib/components/detail/TanstackOrderQuote.svelte b/packages/ui-components/src/lib/components/detail/TanstackOrderQuote.svelte index 0b2716ff8c..eaeb1b25da 100644 --- a/packages/ui-components/src/lib/components/detail/TanstackOrderQuote.svelte +++ b/packages/ui-components/src/lib/components/detail/TanstackOrderQuote.svelte @@ -15,8 +15,15 @@ TableHead, TableHeadCell } from 'flowbite-svelte'; - import { BugOutline, ClipboardOutline, PauseSolid, PlaySolid } from 'flowbite-svelte-icons'; + import { + BugOutline, + ClipboardOutline, + PauseSolid, + PlaySolid, + DatabaseOutline + } from 'flowbite-svelte-icons'; import Tooltip from '../Tooltip.svelte'; + import { Badge } from 'flowbite-svelte'; export let order: RaindexOrder; export let handleQuoteDebugModal: @@ -81,7 +88,20 @@
-

Order quotes

+
+

Order quotes

+ {#if order.oracleUrl} + + + + Oracle + + + + Quotes include signed context data from oracle + + {/if} +
{#if $orderQuoteQuery.data && $orderQuoteQuery.data.length > 0 && isHex($orderQuoteQuery.data[0].blockNumber)} Date: Sun, 15 Feb 2026 19:08:12 +0000 Subject: [PATCH 06/14] revert: remove frontend changes from Phase 3 PR --- .../lib/components/detail/OrderDetail.svelte | 24 ------------------- .../detail/TanstackOrderQuote.svelte | 24 ++----------------- 2 files changed, 2 insertions(+), 46 deletions(-) diff --git a/packages/ui-components/src/lib/components/detail/OrderDetail.svelte b/packages/ui-components/src/lib/components/detail/OrderDetail.svelte index 015107b497..6693b87d60 100644 --- a/packages/ui-components/src/lib/components/detail/OrderDetail.svelte +++ b/packages/ui-components/src/lib/components/detail/OrderDetail.svelte @@ -200,30 +200,6 @@ - {#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]} diff --git a/packages/ui-components/src/lib/components/detail/TanstackOrderQuote.svelte b/packages/ui-components/src/lib/components/detail/TanstackOrderQuote.svelte index eaeb1b25da..0b2716ff8c 100644 --- a/packages/ui-components/src/lib/components/detail/TanstackOrderQuote.svelte +++ b/packages/ui-components/src/lib/components/detail/TanstackOrderQuote.svelte @@ -15,15 +15,8 @@ TableHead, TableHeadCell } from 'flowbite-svelte'; - import { - BugOutline, - ClipboardOutline, - PauseSolid, - PlaySolid, - DatabaseOutline - } from 'flowbite-svelte-icons'; + import { BugOutline, ClipboardOutline, PauseSolid, PlaySolid } from 'flowbite-svelte-icons'; import Tooltip from '../Tooltip.svelte'; - import { Badge } from 'flowbite-svelte'; export let order: RaindexOrder; export let handleQuoteDebugModal: @@ -88,20 +81,7 @@
-
-

Order quotes

- {#if order.oracleUrl} - - - - Oracle - - - - Quotes include signed context data from oracle - - {/if} -
+

Order quotes

{#if $orderQuoteQuery.data && $orderQuoteQuery.data.length > 0 && isHex($orderQuoteQuery.data[0].blockNumber)} Date: Tue, 17 Feb 2026 13:48:43 +0000 Subject: [PATCH 07/14] switch oracle fetch to POST with ABI-encoded body The oracle endpoint now receives order details via POST so it can tailor responses based on the specific order, counterparty, and IO indexes. POST body: abi.encode(OrderV4, inputIOIndex, outputIOIndex, counterparty) Falls back to GET when no body is provided (simple oracles). Callers currently pass None - ABI encoding will be wired in once the order data is available at each call site. --- crates/common/src/oracle.rs | 42 ++++++++++++++----- .../common/src/raindex_client/order_quotes.rs | 2 +- .../src/raindex_client/take_orders/single.rs | 2 +- crates/common/src/take_orders/candidates.rs | 2 +- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/crates/common/src/oracle.rs b/crates/common/src/oracle.rs index 08c0fa5805..d49b0c8f26 100644 --- a/crates/common/src/oracle.rs +++ b/crates/common/src/oracle.rs @@ -38,18 +38,34 @@ impl From for SignedContextV1 { } } -/// Fetch signed context from an oracle endpoint. +/// Fetch signed context from an oracle endpoint via POST. /// -/// The endpoint must respond to a GET request with a JSON body matching -/// `OracleResponse` (signer, context, signature). -pub async fn fetch_signed_context(url: &str) -> Result { +/// 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 `OracleResponse`. +/// +/// If `body` is None, falls back to a GET request (for simple oracles that +/// don't need order details). +pub async fn fetch_signed_context( + url: &str, + body: Option>, +) -> Result { 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: OracleResponse = client - .get(url) + let request = match body { + Some(data) => client + .post(url) + .header("Content-Type", "application/octet-stream") + .body(data), + None => client.get(url), + }; + + let response: OracleResponse = request .send() .await? .error_for_status()? @@ -64,8 +80,14 @@ pub async fn fetch_signed_context(url: &str) -> Result Vec> { - let futures: Vec<_> = urls.iter().map(|url| fetch_signed_context(url)).collect(); +pub async fn fetch_signed_contexts( + urls: &[String], + body: Option>, +) -> Vec> { + let futures: Vec<_> = urls + .iter() + .map(|url| fetch_signed_context(url, body.clone())) + .collect(); futures::future::join_all(futures).await } @@ -96,13 +118,13 @@ mod tests { #[tokio::test] async fn test_fetch_signed_context_invalid_url() { - let result = fetch_signed_context("not-a-url").await; + let result = fetch_signed_context("not-a-url", None).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").await; + let result = fetch_signed_context("http://127.0.0.1:1/oracle", None).await; assert!(result.is_err()); } } diff --git a/crates/common/src/raindex_client/order_quotes.rs b/crates/common/src/raindex_client/order_quotes.rs index e83bffd9fa..8bafdf6e8c 100644 --- a/crates/common/src/raindex_client/order_quotes.rs +++ b/crates/common/src/raindex_client/order_quotes.rs @@ -125,7 +125,7 @@ impl RaindexOrder { // Fetch signed context from oracle if this order has one let signed_context = match self.oracle_url() { - Some(url) => match crate::oracle::fetch_signed_context(&url).await { + Some(url) => match crate::oracle::fetch_signed_context(&url, None).await { Ok(ctx) => vec![ctx], Err(e) => { tracing::warn!("Failed to fetch oracle data from {}: {}", url, e); diff --git a/crates/common/src/raindex_client/take_orders/single.rs b/crates/common/src/raindex_client/take_orders/single.rs index f9d152ca88..b154217938 100644 --- a/crates/common/src/raindex_client/take_orders/single.rs +++ b/crates/common/src/raindex_client/take_orders/single.rs @@ -117,7 +117,7 @@ pub async fn execute_single_take( // Fetch signed context from oracle if URL provided let mut candidate = candidate; if let Some(url) = oracle_url { - match crate::oracle::fetch_signed_context(&url).await { + match crate::oracle::fetch_signed_context(&url, None).await { Ok(ctx) => candidate.signed_context = vec![ctx], Err(e) => { tracing::warn!("Failed to fetch oracle data from {}: {}", url, e); diff --git a/crates/common/src/take_orders/candidates.rs b/crates/common/src/take_orders/candidates.rs index 9c150d4540..12954be052 100644 --- a/crates/common/src/take_orders/candidates.rs +++ b/crates/common/src/take_orders/candidates.rs @@ -134,7 +134,7 @@ async fn fetch_oracle_for_order(order: &RaindexOrder) -> Vec { let url = order.oracle_url(); match url { - Some(oracle_url) => match crate::oracle::fetch_signed_context(&oracle_url).await { + Some(oracle_url) => match crate::oracle::fetch_signed_context(&oracle_url, None).await { Ok(ctx) => vec![ctx], Err(e) => { tracing::warn!("Failed to fetch oracle data from {}: {}", oracle_url, e); From 026a10672705e16fff6db675dc6942ab7261276d Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 17 Feb 2026 13:55:15 +0000 Subject: [PATCH 08/14] oracle fetch: body is required, no GET fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST with ABI-encoded order data is mandatory. Callers currently pass empty vec — will be wired to abi.encode(OrderV4, inputIOIndex, outputIOIndex, counterparty) at each call site. --- crates/common/src/oracle.rs | 24 +++++++------------ .../common/src/raindex_client/order_quotes.rs | 2 +- .../src/raindex_client/take_orders/single.rs | 2 +- crates/common/src/take_orders/candidates.rs | 2 +- 4 files changed, 11 insertions(+), 19 deletions(-) diff --git a/crates/common/src/oracle.rs b/crates/common/src/oracle.rs index d49b0c8f26..b66e14595d 100644 --- a/crates/common/src/oracle.rs +++ b/crates/common/src/oracle.rs @@ -45,27 +45,19 @@ impl From for SignedContextV1 { /// `abi.encode(OrderV4, uint256 inputIOIndex, uint256 outputIOIndex, address counterparty)` /// /// The endpoint must respond with a JSON body matching `OracleResponse`. -/// -/// If `body` is None, falls back to a GET request (for simple oracles that -/// don't need order details). pub async fn fetch_signed_context( url: &str, - body: Option>, + 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()?; - let request = match body { - Some(data) => client - .post(url) - .header("Content-Type", "application/octet-stream") - .body(data), - None => client.get(url), - }; - - let response: OracleResponse = request + let response: OracleResponse = client + .post(url) + .header("Content-Type", "application/octet-stream") + .body(body) .send() .await? .error_for_status()? @@ -82,7 +74,7 @@ pub async fn fetch_signed_context( /// partial failures. pub async fn fetch_signed_contexts( urls: &[String], - body: Option>, + body: Vec, ) -> Vec> { let futures: Vec<_> = urls .iter() @@ -118,13 +110,13 @@ mod tests { #[tokio::test] async fn test_fetch_signed_context_invalid_url() { - let result = fetch_signed_context("not-a-url", None).await; + 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", None).await; + let result = fetch_signed_context("http://127.0.0.1:1/oracle", vec![]).await; assert!(result.is_err()); } } diff --git a/crates/common/src/raindex_client/order_quotes.rs b/crates/common/src/raindex_client/order_quotes.rs index 8bafdf6e8c..6c54261595 100644 --- a/crates/common/src/raindex_client/order_quotes.rs +++ b/crates/common/src/raindex_client/order_quotes.rs @@ -125,7 +125,7 @@ impl RaindexOrder { // Fetch signed context from oracle if this order has one let signed_context = match self.oracle_url() { - Some(url) => match crate::oracle::fetch_signed_context(&url, None).await { + Some(url) => match crate::oracle::fetch_signed_context(&url, vec![]).await { Ok(ctx) => vec![ctx], Err(e) => { tracing::warn!("Failed to fetch oracle data from {}: {}", url, e); diff --git a/crates/common/src/raindex_client/take_orders/single.rs b/crates/common/src/raindex_client/take_orders/single.rs index b154217938..290733d5b1 100644 --- a/crates/common/src/raindex_client/take_orders/single.rs +++ b/crates/common/src/raindex_client/take_orders/single.rs @@ -117,7 +117,7 @@ pub async fn execute_single_take( // Fetch signed context from oracle if URL provided let mut candidate = candidate; if let Some(url) = oracle_url { - match crate::oracle::fetch_signed_context(&url, None).await { + match crate::oracle::fetch_signed_context(&url, vec![]).await { Ok(ctx) => candidate.signed_context = vec![ctx], Err(e) => { tracing::warn!("Failed to fetch oracle data from {}: {}", url, e); diff --git a/crates/common/src/take_orders/candidates.rs b/crates/common/src/take_orders/candidates.rs index 12954be052..da202dedfb 100644 --- a/crates/common/src/take_orders/candidates.rs +++ b/crates/common/src/take_orders/candidates.rs @@ -134,7 +134,7 @@ async fn fetch_oracle_for_order(order: &RaindexOrder) -> Vec { let url = order.oracle_url(); match url { - Some(oracle_url) => match crate::oracle::fetch_signed_context(&oracle_url, None).await { + Some(oracle_url) => match crate::oracle::fetch_signed_context(&oracle_url, vec![]).await { Ok(ctx) => vec![ctx], Err(e) => { tracing::warn!("Failed to fetch oracle data from {}: {}", oracle_url, e); From aec3e74fb77cd857558f0152f00e956d6d553d59 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 17 Feb 2026 14:05:07 +0000 Subject: [PATCH 09/14] wire ABI-encoded per-pair oracle fetches - encode_oracle_body: abi.encode(OrderV4, inputIOIndex, outputIOIndex, counterparty) - get_quotes: fetches oracle per IO pair concurrently, counterparty=address(0) - build_take_order_candidates: fetches oracle per quote pair - execute_single_take: encodes with actual taker as counterparty - get_order_quotes_with_context_fn: accepts per-pair context callback --- crates/common/src/oracle.rs | 23 ++- .../common/src/raindex_client/order_quotes.rs | 70 +++++++-- .../src/raindex_client/take_orders/single.rs | 8 +- crates/common/src/take_orders/candidates.rs | 140 +++++++++--------- crates/quote/src/order_quotes.rs | 20 ++- 5 files changed, 172 insertions(+), 89 deletions(-) diff --git a/crates/common/src/oracle.rs b/crates/common/src/oracle.rs index b66e14595d..f6b65d2d01 100644 --- a/crates/common/src/oracle.rs +++ b/crates/common/src/oracle.rs @@ -1,5 +1,6 @@ -use alloy::primitives::{Address, Bytes, FixedBytes}; -use rain_orderbook_bindings::IOrderBookV6::SignedContextV1; +use alloy::primitives::{Address, Bytes, FixedBytes, U256}; +use alloy::sol_types::SolValue; +use rain_orderbook_bindings::IOrderBookV6::{OrderV4, SignedContextV1}; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -38,6 +39,24 @@ impl From for SignedContextV1 { } } +/// Encode the POST body for an 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() +} + /// Fetch signed context from an oracle endpoint via POST. /// /// The endpoint receives an ABI-encoded body containing the order details diff --git a/crates/common/src/raindex_client/order_quotes.rs b/crates/common/src/raindex_client/order_quotes.rs index 6c54261595..762215709c 100644 --- a/crates/common/src/raindex_client/order_quotes.rs +++ b/crates/common/src/raindex_client/order_quotes.rs @@ -1,8 +1,10 @@ use super::*; use crate::raindex_client::orders::RaindexOrder; use rain_math_float::Float; +use alloy::primitives::Address; +use rain_orderbook_bindings::IOrderBookV6::OrderV4; use rain_orderbook_quote::{ - get_order_quotes_with_context, BatchOrderQuotesResponse, OrderQuoteValue, Pair, + get_order_quotes_with_context_fn, BatchOrderQuotesResponse, OrderQuoteValue, Pair, }; use rain_orderbook_subgraph_client::utils::float::{F0, F1}; use std::ops::{Div, Mul}; @@ -122,25 +124,65 @@ impl RaindexOrder { ) -> Result, RaindexError> { let gas_amount = gas.map(|v| v.parse::()).transpose()?; let rpcs = self.get_rpc_urls()?; + let oracle_url = self.oracle_url(); + let sg_order = self.clone().into_sg_order()?; + let order_v4: OrderV4 = sg_order.clone().try_into()?; - // Fetch signed context from oracle if this order has one - let signed_context = match self.oracle_url() { - Some(url) => match crate::oracle::fetch_signed_context(&url, vec![]).await { - Ok(ctx) => vec![ctx], - Err(e) => { - tracing::warn!("Failed to fetch oracle data from {}: {}", url, e); - vec![] + // Pre-fetch oracle context for each IO pair concurrently + let mut pair_contexts: std::collections::HashMap<(usize, usize), Vec> = + std::collections::HashMap::new(); + + if let Some(ref url) = oracle_url { + let mut fetch_futures = vec![]; + for input_index in 0..order_v4.validInputs.len() { + for output_index in 0..order_v4.validOutputs.len() { + if order_v4.validInputs[input_index].token + != order_v4.validOutputs[output_index].token + { + let body = crate::oracle::encode_oracle_body( + &order_v4, + input_index as u32, + output_index as u32, + Address::ZERO, // counterparty unknown at quote time + ); + let url = url.clone(); + fetch_futures.push(async move { + let result = crate::oracle::fetch_signed_context(&url, body).await; + (input_index, output_index, result) + }); + } } - }, - None => vec![], - }; + } + + let results = futures::future::join_all(fetch_futures).await; + for (input_index, output_index, result) in results { + match result { + Ok(ctx) => { + pair_contexts.insert((input_index, output_index), vec![ctx]); + } + Err(e) => { + tracing::warn!( + "Failed to fetch oracle for pair ({}, {}): {}", + input_index, + output_index, + e + ); + } + } + } + } - let order_quotes = get_order_quotes_with_context( - vec![self.clone().into_sg_order()?], + let order_quotes = get_order_quotes_with_context_fn( + vec![sg_order], block_number, rpcs.iter().map(|s| s.to_string()).collect(), gas_amount, - signed_context, + |_order, input_index, output_index| { + pair_contexts + .get(&(input_index, output_index)) + .cloned() + .unwrap_or_default() + }, ) .await?; diff --git a/crates/common/src/raindex_client/take_orders/single.rs b/crates/common/src/raindex_client/take_orders/single.rs index 290733d5b1..776d896f10 100644 --- a/crates/common/src/raindex_client/take_orders/single.rs +++ b/crates/common/src/raindex_client/take_orders/single.rs @@ -117,7 +117,13 @@ pub async fn execute_single_take( // Fetch signed context from oracle if URL provided let mut candidate = candidate; if let Some(url) = oracle_url { - match crate::oracle::fetch_signed_context(&url, vec![]).await { + 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); diff --git a/crates/common/src/take_orders/candidates.rs b/crates/common/src/take_orders/candidates.rs index da202dedfb..cd7b1731c0 100644 --- a/crates/common/src/take_orders/candidates.rs +++ b/crates/common/src/take_orders/candidates.rs @@ -56,32 +56,6 @@ fn get_orderbook_address(order: &RaindexOrder) -> Result } } -fn build_candidates_for_order( - order: &RaindexOrder, - quotes: Vec, - input_token: Address, - output_token: Address, - signed_context: Vec, -) -> 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, - signed_context.clone(), - ) - }) - .collect::, _>>() - .map(|opts| opts.into_iter().flatten().collect()) -} - pub async fn build_take_order_candidates_for_pair( orders: &[RaindexOrder], input_token: Address, @@ -91,57 +65,81 @@ pub async fn build_take_order_candidates_for_pair( ) -> Result, RaindexError> { let gas_string = gas.map(|g| g.to_string()); - type QuoteWithContext = ( - Result, RaindexError>, - Vec, - ); - - // Fetch quotes and oracle data concurrently for each order - let results: Vec = futures::stream::iter(orders.iter().map(|order| { - let gas_string = gas_string.clone(); - async move { - let quotes = order.get_quotes(block_number, gas_string).await; - let signed_context = fetch_oracle_for_order(order).await; - (quotes, signed_context) - } - })) - .buffered(DEFAULT_QUOTE_CONCURRENCY) - .collect() - .await; - - orders - .iter() - .zip(results) - .map(|(order, (quotes_result, signed_context))| { - build_candidates_for_order( - order, - quotes_result?, + // 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(DEFAULT_QUOTE_CONCURRENCY) + .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, - ) - }) - .collect::, _>>() - .map(|vecs| vecs.into_iter().flatten().collect()) + )? { + all_candidates.push(candidate); + } + } + } + + Ok(all_candidates) } -/// Fetch signed context from an order's oracle endpoint, if it has one. +/// 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_order(order: &RaindexOrder) -> Vec { - #[cfg(target_family = "wasm")] - let url = order.oracle_url(); - #[cfg(not(target_family = "wasm"))] - let url = order.oracle_url(); - - match url { - Some(oracle_url) => match crate::oracle::fetch_signed_context(&oracle_url, vec![]).await { - Ok(ctx) => vec![ctx], - Err(e) => { - tracing::warn!("Failed to fetch oracle data from {}: {}", oracle_url, e); - vec![] - } - }, - None => vec![], +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![] + } } } diff --git a/crates/quote/src/order_quotes.rs b/crates/quote/src/order_quotes.rs index db6f7dc291..2adf825585 100644 --- a/crates/quote/src/order_quotes.rs +++ b/crates/quote/src/order_quotes.rs @@ -55,6 +55,23 @@ pub async fn get_order_quotes_with_context( rpcs: Vec, gas: Option, signed_context: Vec, +) -> Result, Error> { + // Build a closure that returns the same context for every pair + let context_fn = |_order: &OrderV4, _input_index: usize, _output_index: usize| { + signed_context.clone() + }; + get_order_quotes_with_context_fn(orders, block_number, rpcs, gas, context_fn).await +} + +/// Get order quotes with a per-pair signed context function. +/// The context_fn is called for each (order, inputIOIndex, outputIOIndex) to produce +/// the signed context for that specific quote target. +pub async fn get_order_quotes_with_context_fn( + orders: Vec, + block_number: Option, + rpcs: Vec, + gas: Option, + context_fn: impl Fn(&OrderV4, usize, usize) -> Vec, ) -> Result, Error> { let mut results: Vec = Vec::new(); @@ -101,13 +118,14 @@ pub async fn get_order_quotes_with_context( .unwrap_or("UNKNOWN".to_string()) ); + let pair_context = context_fn(&order_struct, input_index, output_index); let quote_target = QuoteTarget { orderbook, quote_config: QuoteV2 { order: order_struct.clone(), inputIOIndex: U256::from(input_index), outputIOIndex: U256::from(output_index), - signedContext: signed_context.clone(), + signedContext: pair_context, }, }; From 63ea51f18521a3a02402faf9cf54cb19bc2f3ca4 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 17 Feb 2026 15:23:15 +0000 Subject: [PATCH 10/14] refactor: move oracle into quote crate, remove closure pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Oracle fetch logic moved from common to quote crate (common re-exports) - get_order_quotes now extracts oracle URL directly from SgOrder.meta - Removed get_order_quotes_with_context and get_order_quotes_with_context_fn - No more closures, HashMaps, or pre-fetching — oracle context fetched inline per IO pair inside the quote loop - RaindexOrder.get_quotes() simplified to just call get_order_quotes() --- crates/common/src/oracle.rs | 144 +----------------- .../common/src/raindex_client/order_quotes.rs | 58 +------ crates/quote/Cargo.toml | 2 + crates/quote/src/lib.rs | 1 + crates/quote/src/oracle.rs | 140 +++++++++++++++++ crates/quote/src/order_quotes.rs | 67 ++++---- 6 files changed, 183 insertions(+), 229 deletions(-) create mode 100644 crates/quote/src/oracle.rs diff --git a/crates/common/src/oracle.rs b/crates/common/src/oracle.rs index f6b65d2d01..7dfbcf95a6 100644 --- a/crates/common/src/oracle.rs +++ b/crates/common/src/oracle.rs @@ -1,141 +1,3 @@ -use alloy::primitives::{Address, Bytes, FixedBytes, U256}; -use alloy::sol_types::SolValue; -use rain_orderbook_bindings::IOrderBookV6::{OrderV4, SignedContextV1}; -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 an 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() -} - -/// Fetch signed context from an oracle endpoint via POST. -/// -/// 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 `OracleResponse`. -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()?; - - let response: OracleResponse = client - .post(url) - .header("Content-Type", "application/octet-stream") - .body(body) - .send() - .await? - .error_for_status()? - .json() - .await?; - - Ok(response.into()) -} - -/// Fetch signed contexts for multiple oracle URLs concurrently. -/// -/// Returns a vec of results - one per URL. Failed fetches return errors -/// rather than failing the entire batch, so callers can decide how to handle -/// partial failures. -pub async fn fetch_signed_contexts( - urls: &[String], - body: Vec, -) -> Vec> { - let futures: Vec<_> = urls - .iter() - .map(|url| fetch_signed_context(url, body.clone())) - .collect(); - - futures::future::join_all(futures).await -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy::primitives::{address, FixedBytes}; - - #[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])); - } - - #[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()); - } -} +// 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::*; diff --git a/crates/common/src/raindex_client/order_quotes.rs b/crates/common/src/raindex_client/order_quotes.rs index 762215709c..59aa24cb85 100644 --- a/crates/common/src/raindex_client/order_quotes.rs +++ b/crates/common/src/raindex_client/order_quotes.rs @@ -1,10 +1,8 @@ use super::*; use crate::raindex_client::orders::RaindexOrder; use rain_math_float::Float; -use alloy::primitives::Address; -use rain_orderbook_bindings::IOrderBookV6::OrderV4; use rain_orderbook_quote::{ - get_order_quotes_with_context_fn, BatchOrderQuotesResponse, OrderQuoteValue, Pair, + get_order_quotes, BatchOrderQuotesResponse, OrderQuoteValue, Pair, }; use rain_orderbook_subgraph_client::utils::float::{F0, F1}; use std::ops::{Div, Mul}; @@ -124,65 +122,13 @@ impl RaindexOrder { ) -> Result, RaindexError> { let gas_amount = gas.map(|v| v.parse::()).transpose()?; let rpcs = self.get_rpc_urls()?; - let oracle_url = self.oracle_url(); let sg_order = self.clone().into_sg_order()?; - let order_v4: OrderV4 = sg_order.clone().try_into()?; - // Pre-fetch oracle context for each IO pair concurrently - let mut pair_contexts: std::collections::HashMap<(usize, usize), Vec> = - std::collections::HashMap::new(); - - if let Some(ref url) = oracle_url { - let mut fetch_futures = vec![]; - for input_index in 0..order_v4.validInputs.len() { - for output_index in 0..order_v4.validOutputs.len() { - if order_v4.validInputs[input_index].token - != order_v4.validOutputs[output_index].token - { - let body = crate::oracle::encode_oracle_body( - &order_v4, - input_index as u32, - output_index as u32, - Address::ZERO, // counterparty unknown at quote time - ); - let url = url.clone(); - fetch_futures.push(async move { - let result = crate::oracle::fetch_signed_context(&url, body).await; - (input_index, output_index, result) - }); - } - } - } - - let results = futures::future::join_all(fetch_futures).await; - for (input_index, output_index, result) in results { - match result { - Ok(ctx) => { - pair_contexts.insert((input_index, output_index), vec![ctx]); - } - Err(e) => { - tracing::warn!( - "Failed to fetch oracle for pair ({}, {}): {}", - input_index, - output_index, - e - ); - } - } - } - } - - let order_quotes = get_order_quotes_with_context_fn( + let order_quotes = get_order_quotes( vec![sg_order], block_number, rpcs.iter().map(|s| s.to_string()).collect(), gas_amount, - |_order, input_index, output_index| { - pair_contexts - .get(&(input_index, output_index)) - .cloned() - .unwrap_or_default() - }, ) .await?; 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..3e7759914f --- /dev/null +++ b/crates/quote/src/oracle.rs @@ -0,0 +1,140 @@ +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 an 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() +} + +/// Fetch signed context from an oracle endpoint via POST. +/// +/// 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 `OracleResponse`. +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()?; + + let response: OracleResponse = client + .post(url) + .header("Content-Type", "application/octet-stream") + .body(body) + .send() + .await? + .error_for_status()? + .json() + .await?; + + Ok(response.into()) +} + +/// 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}; + + #[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])); + } + + #[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()); + } +} diff --git a/crates/quote/src/order_quotes.rs b/crates/quote/src/order_quotes.rs index 2adf825585..f64b67a92f 100644 --- a/crates/quote/src/order_quotes.rs +++ b/crates/quote/src/order_quotes.rs @@ -5,7 +5,7 @@ use crate::{ }; use alloy::primitives::{Address, U256}; use alloy_ethers_typecast::ReadableClient; -use rain_orderbook_bindings::IOrderBookV6::{OrderV4, QuoteV2, SignedContextV1}; +use rain_orderbook_bindings::IOrderBookV6::{OrderV4, QuoteV2}; use rain_orderbook_subgraph_client::types::common::SgOrder; use serde::{Deserialize, Serialize}; use std::str::FromStr; @@ -38,40 +38,15 @@ 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, rpcs: Vec, gas: Option, -) -> Result, Error> { - get_order_quotes_with_context(orders, block_number, rpcs, gas, vec![]).await -} - -/// Get order quotes with optional signed context data. -/// The signed_context is applied to all quote targets for all orders. -pub async fn get_order_quotes_with_context( - orders: Vec, - block_number: Option, - rpcs: Vec, - gas: Option, - signed_context: Vec, -) -> Result, Error> { - // Build a closure that returns the same context for every pair - let context_fn = |_order: &OrderV4, _input_index: usize, _output_index: usize| { - signed_context.clone() - }; - get_order_quotes_with_context_fn(orders, block_number, rpcs, gas, context_fn).await -} - -/// Get order quotes with a per-pair signed context function. -/// The context_fn is called for each (order, inputIOIndex, outputIOIndex) to produce -/// the signed context for that specific quote target. -pub async fn get_order_quotes_with_context_fn( - orders: Vec, - block_number: Option, - rpcs: Vec, - gas: Option, - context_fn: impl Fn(&OrderV4, usize, usize) -> Vec, ) -> Result, Error> { let mut results: Vec = Vec::new(); @@ -89,6 +64,7 @@ pub async fn get_order_quotes_with_context_fn( let mut quote_targets: Vec = Vec::new(); 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() { @@ -118,14 +94,41 @@ pub async fn get_order_quotes_with_context_fn( .unwrap_or("UNKNOWN".to_string()) ); - let pair_context = context_fn(&order_struct, input_index, output_index); + // 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![] + }; + let quote_target = QuoteTarget { orderbook, quote_config: QuoteV2 { order: order_struct.clone(), inputIOIndex: U256::from(input_index), outputIOIndex: U256::from(output_index), - signedContext: pair_context, + signedContext: signed_context, }, }; From 2ac0d9b007e1b6c8de401faa2eb3745e7a74edb2 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 23 Feb 2026 00:37:17 +0000 Subject: [PATCH 11/14] fmt: cargo fmt fixes --- crates/common/src/raindex_client/order_quotes.rs | 4 +--- crates/common/src/take_orders/candidates.rs | 11 ++++++++--- crates/common/src/take_orders/config.rs | 4 +--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/common/src/raindex_client/order_quotes.rs b/crates/common/src/raindex_client/order_quotes.rs index 59aa24cb85..67fd0426ea 100644 --- a/crates/common/src/raindex_client/order_quotes.rs +++ b/crates/common/src/raindex_client/order_quotes.rs @@ -1,9 +1,7 @@ use super::*; use crate::raindex_client::orders::RaindexOrder; use rain_math_float::Float; -use rain_orderbook_quote::{ - get_order_quotes, BatchOrderQuotesResponse, OrderQuoteValue, Pair, -}; +use rain_orderbook_quote::{get_order_quotes, BatchOrderQuotesResponse, OrderQuoteValue, Pair}; use rain_orderbook_subgraph_client::utils::float::{F0, F1}; use std::ops::{Div, Mul}; diff --git a/crates/common/src/take_orders/candidates.rs b/crates/common/src/take_orders/candidates.rs index cd7b1731c0..5f255935ed 100644 --- a/crates/common/src/take_orders/candidates.rs +++ b/crates/common/src/take_orders/candidates.rs @@ -83,9 +83,13 @@ pub async fn build_take_order_candidates_for_pair( let orderbook = get_orderbook_address(order)?; let oracle_url = { #[cfg(target_family = "wasm")] - { order.oracle_url() } + { + order.oracle_url() + } #[cfg(not(target_family = "wasm"))] - { order.oracle_url() } + { + order.oracle_url() + } }; for quote in "es { @@ -128,7 +132,8 @@ async fn fetch_oracle_for_pair( output_io_index: u32, counterparty: Address, ) -> Vec { - let body = crate::oracle::encode_oracle_body(order, input_io_index, output_io_index, counterparty); + 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) => { diff --git a/crates/common/src/take_orders/config.rs b/crates/common/src/take_orders/config.rs index f9d0783bcf..b05b06b39b 100644 --- a/crates/common/src/take_orders/config.rs +++ b/crates/common/src/take_orders/config.rs @@ -2,9 +2,7 @@ use super::simulation::SimulationResult; use crate::raindex_client::RaindexError; use alloy::primitives::{Bytes, U256}; use rain_math_float::Float; -use rain_orderbook_bindings::IOrderBookV6::{ - TakeOrderConfigV4, TakeOrdersConfigV5, -}; +use rain_orderbook_bindings::IOrderBookV6::{TakeOrderConfigV4, TakeOrdersConfigV5}; use serde::{Deserialize, Serialize}; use wasm_bindgen_utils::{impl_wasm_traits, prelude::*}; From d5f6698f71623dcc6bb320bf419dc57f799bdb87 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 23 Feb 2026 20:26:38 +0000 Subject: [PATCH 12/14] feat: update oracle to batch format - Add encode_oracle_body_batch() for array encoding: abi.encode((OrderV4, uint256, uint256, address)[]) - Update fetch_signed_context_batch() to handle Vec responses - Maintain backward compatibility with single request functions - Add comprehensive tests for both single and batch formats - Response format now expects JSON array per spec --- crates/quote/src/oracle.rs | 127 +++++++++++++++++++++++++++++++++++-- 1 file changed, 122 insertions(+), 5 deletions(-) diff --git a/crates/quote/src/oracle.rs b/crates/quote/src/oracle.rs index 3e7759914f..620989547e 100644 --- a/crates/quote/src/oracle.rs +++ b/crates/quote/src/oracle.rs @@ -40,7 +40,7 @@ impl From for SignedContextV1 { } } -/// Encode the POST body for an oracle request. +/// 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( @@ -58,13 +58,36 @@ pub fn encode_oracle_body( .abi_encode() } -/// Fetch signed context from an oracle endpoint via POST. +/// 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 `OracleResponse`. +/// 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, @@ -74,7 +97,8 @@ pub async fn fetch_signed_context( let builder = builder.timeout(std::time::Duration::from_secs(10)); let client = builder.build()?; - let response: OracleResponse = client + // 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) @@ -83,8 +107,43 @@ pub async fn fetch_signed_context( .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()) +} - Ok(response.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. @@ -106,6 +165,7 @@ pub fn extract_oracle_url(order: &SgOrder) -> Option { 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() { @@ -126,6 +186,31 @@ mod tests { 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; @@ -137,4 +222,36 @@ mod tests { 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, + } + } } From 96a257e86373dc4cd5ff0b025a0ba388924507ca Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 23 Feb 2026 21:23:44 +0000 Subject: [PATCH 13/14] fix: cargo fmt formatting --- Cargo.lock | 2 ++ crates/quote/src/oracle.rs | 53 ++++++++++++++++++++++++++------------ 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5ea1961212..fb98d14e65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7067,12 +7067,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/quote/src/oracle.rs b/crates/quote/src/oracle.rs index 620989547e..4b6b27045d 100644 --- a/crates/quote/src/oracle.rs +++ b/crates/quote/src/oracle.rs @@ -61,9 +61,7 @@ pub fn encode_oracle_body( /// 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 { +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)| { @@ -75,7 +73,7 @@ pub fn encode_oracle_body_batch( ) }) .collect(); - + tuples.abi_encode() } @@ -86,7 +84,7 @@ pub fn encode_oracle_body_batch( /// `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, @@ -107,13 +105,14 @@ pub async fn fetch_signed_context( .error_for_status()? .json() .await?; - + if response.len() != 1 { - return Err(OracleError::InvalidResponse( - format!("Expected 1 response, got {}", response.len()) - )); + return Err(OracleError::InvalidResponse(format!( + "Expected 1 response, got {}", + response.len() + ))); } - + Ok(response.into_iter().next().unwrap().into()) } @@ -189,7 +188,12 @@ mod tests { #[test] fn test_encode_oracle_body_single() { let order = create_test_order(); - let body = encode_oracle_body(&order, 1, 2, address!("0x1111111111111111111111111111111111111111")); + let body = encode_oracle_body( + &order, + 1, + 2, + address!("0x1111111111111111111111111111111111111111"), + ); assert!(!body.is_empty()); } @@ -197,17 +201,32 @@ mod tests { 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")), + ( + &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")); + let single_body = encode_oracle_body( + &order1, + 1, + 2, + address!("0x1111111111111111111111111111111111111111"), + ); assert_ne!(body, single_body); } From 3a0d44c98bffbe107858a195a689f368760f96ad Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 24 Feb 2026 12:21:59 +0000 Subject: [PATCH 14/14] fix: cargo fmt import ordering in oracle.rs --- crates/quote/src/oracle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/quote/src/oracle.rs b/crates/quote/src/oracle.rs index 4b6b27045d..569fdab2ef 100644 --- a/crates/quote/src/oracle.rs +++ b/crates/quote/src/oracle.rs @@ -164,7 +164,7 @@ pub fn extract_oracle_url(order: &SgOrder) -> Option { mod tests { use super::*; use alloy::primitives::{address, FixedBytes}; - use rain_orderbook_bindings::IOrderBookV6::{EvaluableV4, IOV2, OrderV4}; + use rain_orderbook_bindings::IOrderBookV6::{EvaluableV4, OrderV4, IOV2}; #[test] fn test_oracle_response_to_signed_context() {