diff --git a/crates/common/src/add_order.rs b/crates/common/src/add_order.rs index 49fd1433c1..0ba879503a 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, @@ -683,6 +699,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()))), @@ -803,6 +820,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()))), @@ -966,6 +984,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()))), @@ -1307,6 +1326,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..7dfbcf95a6 --- /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::*; diff --git a/crates/common/src/parsed_meta.rs b/crates/common/src/parsed_meta.rs index 38cb74af47..16d6949fc4 100644 --- a/crates/common/src/parsed_meta.rs +++ b/crates/common/src/parsed_meta.rs @@ -1,5 +1,8 @@ use rain_metadata::{ - types::dotrain::{gui_state_v1::DotrainGuiStateV1, source_v1::DotrainSourceV1}, + types::{ + dotrain::{gui_state_v1::DotrainGuiStateV1, source_v1::DotrainSourceV1}, + raindex_signed_context_oracle::RaindexSignedContextOracleV1, + }, KnownMagic, RainMetaDocumentV1Item, }; use serde::{Deserialize, Serialize}; @@ -13,6 +16,7 @@ use wasm_bindgen_utils::{impl_wasm_traits, prelude::*}; pub enum ParsedMeta { DotrainGuiStateV1(DotrainGuiStateV1), DotrainSourceV1(DotrainSourceV1), + RaindexSignedContextOracleV1(RaindexSignedContextOracleV1), } #[cfg(target_family = "wasm")] impl_wasm_traits!(ParsedMeta); @@ -34,6 +38,10 @@ impl ParsedMeta { let source = DotrainSourceV1::try_from(item.clone())?; Ok(Some(ParsedMeta::DotrainSourceV1(source))) } + KnownMagic::RaindexSignedContextOracleV1 => { + let oracle = RaindexSignedContextOracleV1::try_from(item.clone())?; + Ok(Some(ParsedMeta::RaindexSignedContextOracleV1(oracle))) + } // Filter out all other metadata types - they're not needed for the frontend _ => Ok(None), } @@ -198,4 +206,67 @@ mod tests { assert!(matches!(&parsed[0], ParsedMeta::DotrainSourceV1(s) if s.0 == source.0)); assert!(matches!(&parsed[1], ParsedMeta::DotrainGuiStateV1(g) if g == &gui)); } + + #[test] + fn test_from_meta_item_raindex_signed_context_oracle_v1() { + let oracle = + RaindexSignedContextOracleV1::parse("https://oracle.example.com/prices/eth-usd") + .unwrap(); + let item = oracle.to_meta_item(); + let result = ParsedMeta::from_meta_item(&item).unwrap(); + match result.unwrap() { + ParsedMeta::RaindexSignedContextOracleV1(parsed_oracle) => { + assert_eq!( + parsed_oracle.url(), + "https://oracle.example.com/prices/eth-usd" + ); + } + _ => panic!("Expected RaindexSignedContextOracleV1"), + } + } + + #[test] + fn test_parse_multiple_with_oracle() { + let source = get_default_dotrain_source(); + let oracle = + RaindexSignedContextOracleV1::parse("https://oracle.example.com/feed").unwrap(); + + let items = vec![ + RainMetaDocumentV1Item::from(source.clone()), + oracle.to_meta_item(), + ]; + + let results = ParsedMeta::parse_multiple(&items).unwrap(); + assert_eq!(results.len(), 2); + + match &results[0] { + ParsedMeta::DotrainSourceV1(parsed_source) => { + assert_eq!(parsed_source.0, source.0); + } + _ => panic!("Expected DotrainSourceV1"), + } + + match &results[1] { + ParsedMeta::RaindexSignedContextOracleV1(parsed_oracle) => { + assert_eq!(parsed_oracle.url(), "https://oracle.example.com/feed"); + } + _ => panic!("Expected RaindexSignedContextOracleV1"), + } + } + + #[test] + fn test_parse_from_bytes_with_oracle() { + let oracle = + RaindexSignedContextOracleV1::parse("https://oracle.example.com/prices/eth-usd") + .unwrap(); + let items = vec![oracle.to_meta_item()]; + let bytes = RainMetaDocumentV1Item::cbor_encode_seq(&items, KnownMagic::RainMetaDocumentV1) + .unwrap(); + + let parsed = ParsedMeta::parse_from_bytes(&bytes).unwrap(); + assert_eq!(parsed.len(), 1); + assert!( + matches!(&parsed[0], ParsedMeta::RaindexSignedContextOracleV1(o) if o.url() == "https://oracle.example.com/prices/eth-usd") + ); + } } diff --git a/crates/common/src/raindex_client/order_quotes.rs b/crates/common/src/raindex_client/order_quotes.rs index 550cba56ea..67fd0426ea 100644 --- a/crates/common/src/raindex_client/order_quotes.rs +++ b/crates/common/src/raindex_client/order_quotes.rs @@ -120,8 +120,10 @@ impl RaindexOrder { ) -> Result, RaindexError> { let gas_amount = gas.map(|v| v.parse::()).transpose()?; let rpcs = self.get_rpc_urls()?; + let sg_order = self.clone().into_sg_order()?; + let order_quotes = get_order_quotes( - vec![self.clone().into_sg_order()?], + vec![sg_order], block_number, rpcs.iter().map(|s| s.to_string()).collect(), gas_amount, diff --git a/crates/common/src/raindex_client/orders.rs b/crates/common/src/raindex_client/orders.rs index 664c72f97b..2ccfab9fb3 100644 --- a/crates/common/src/raindex_client/orders.rs +++ b/crates/common/src/raindex_client/orders.rs @@ -272,6 +272,14 @@ impl RaindexOrder { _ => None, }) } + /// Returns the signed context oracle URL if this order has oracle metadata. + #[wasm_bindgen(getter = oracleUrl)] + pub fn oracle_url(&self) -> Option { + self.parsed_meta().into_iter().find_map(|meta| match meta { + ParsedMeta::RaindexSignedContextOracleV1(oracle) => Some(oracle.0), + _ => None, + }) + } #[wasm_bindgen(getter)] pub fn transaction(&self) -> Option { self.transaction.clone() @@ -300,6 +308,16 @@ impl RaindexOrder { pub fn inputs_outputs_list(&self) -> RaindexVaultsList { RaindexVaultsList::new(get_io_by_type(self, RaindexVaultType::InputOutput)) } + + /// 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) + } } #[cfg(not(target_family = "wasm"))] impl RaindexOrder { @@ -348,6 +366,13 @@ impl RaindexOrder { _ => None, }) } + /// Returns the signed context oracle URL if this order has oracle metadata. + pub fn oracle_url(&self) -> Option { + self.parsed_meta().into_iter().find_map(|meta| match meta { + ParsedMeta::RaindexSignedContextOracleV1(oracle) => Some(oracle.0), + _ => None, + }) + } pub fn transaction(&self) -> Option { self.transaction.clone() } @@ -369,6 +394,19 @@ impl RaindexOrder { pub fn inputs_outputs_list(&self) -> RaindexVaultsList { RaindexVaultsList::new(get_io_by_type(self, RaindexVaultType::InputOutput)) } + + /// 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()) + } } fn get_vaults_with_type( @@ -661,6 +699,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..776d896f10 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 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..5f255935ed 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 { @@ -54,22 +56,6 @@ 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, @@ -79,7 +65,8 @@ 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 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 } @@ -88,14 +75,77 @@ pub async fn build_take_order_candidates_for_pair( .collect() .await; - orders - .iter() - .zip(quote_results) - .map(|(order, quotes_result)| { - build_candidates_for_order(order, quotes_result?, input_token, output_token) - }) - .collect::, _>>() - .map(|vecs| vecs.into_iter().flatten().collect()) + // 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( @@ -104,6 +154,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, @@ -138,6 +189,7 @@ fn try_build_candidate( output_io_index, max_output: data.max_output, ratio: data.ratio, + signed_context, })) } @@ -263,7 +315,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).unwrap(); + let result = + try_build_candidate(orderbook, &order, "e, token_b, token_a, vec![]).unwrap(); assert!(result.is_none()); } @@ -279,7 +332,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).unwrap(); + let result = + try_build_candidate(orderbook, &order, "e, token_a, token_b, vec![]).unwrap(); assert!(result.is_none()); } @@ -295,7 +349,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).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 +369,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(), @@ -337,8 +392,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); + 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" @@ -349,8 +410,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); + 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" diff --git a/crates/common/src/take_orders/config.rs b/crates/common/src/take_orders/config.rs index 5e6ec8a371..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::{ - SignedContextV1, TakeOrderConfigV4, TakeOrdersConfigV5, -}; +use rain_orderbook_bindings::IOrderBookV6::{TakeOrderConfigV4, TakeOrdersConfigV5}; use serde::{Deserialize, Serialize}; use wasm_bindgen_utils::{impl_wasm_traits, prelude::*}; @@ -93,7 +91,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..620989547e --- /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, + } + } +} diff --git a/crates/quote/src/order_quotes.rs b/crates/quote/src/order_quotes.rs index 29a71d69fe..f64b67a92f 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, @@ -60,6 +64,7 @@ pub async fn get_order_quotes( 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() { @@ -89,13 +94,41 @@ 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![] + }; + let quote_target = QuoteTarget { orderbook, quote_config: QuoteV2 { 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/gui.rs b/crates/settings/src/gui.rs index ccd2bbcea5..ae7d4d44e1 100644 --- a/crates/settings/src/gui.rs +++ b/crates/settings/src/gui.rs @@ -1196,6 +1196,7 @@ mod tests { network: mock_network(), deployer: None, orderbook: None, + oracle_url: None, }; let deployment = DeploymentCfg { document: Arc::new(RwLock::new(StrictYaml::String("".to_string()))), diff --git a/crates/settings/src/order.rs b/crates/settings/src/order.rs index 6009adaff0..19264fce60 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"]; #[cfg(target_family = "wasm")] use wasm_bindgen_utils::{impl_wasm_traits, prelude::*}; @@ -59,6 +59,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); @@ -813,6 +816,8 @@ impl YamlParsableHash for OrderCfg { }) .collect::, YamlError>>()?; + let oracle_url = optional_string(order_yaml, "oracle-url"); + let order = OrderCfg { document: document.clone(), key: order_key.clone(), @@ -823,6 +828,7 @@ impl YamlParsableHash for OrderCfg { )?, deployer, orderbook, + oracle_url, }; if orders.contains_key(&order_key) { @@ -932,6 +938,7 @@ impl Default for OrderCfg { network: Arc::new(NetworkCfg::default()), deployer: None, orderbook: None, + oracle_url: None, } } } @@ -944,6 +951,7 @@ impl PartialEq for OrderCfg { && self.network == other.network && self.deployer == other.deployer && self.orderbook == other.orderbook + && self.oracle_url == other.oracle_url } } @@ -1479,4 +1487,52 @@ orders: .unwrap(); assert_eq!(inputs.len(), 1); } + + #[test] + fn test_parse_order_with_oracle_url() { + let yaml = r#" +networks: + mainnet: + rpcs: + - "https://mainnet.infura.io" + chain-id: "1" +deployers: + mainnet: + address: 0x0000000000000000000000000000000000000001 + network: mainnet +tokens: + token-one: + network: mainnet + address: 0x1234567890123456789012345678901234567890 + token-two: + network: mainnet + address: 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +orders: + oracle-order: + deployer: mainnet + oracle-url: https://my-oracle.example.com/context + inputs: + - token: token-one + outputs: + - token: token-two + plain-order: + deployer: mainnet + inputs: + - token: token-one + outputs: + - token: token-two +"#; + let orders = OrderCfg::parse_all_from_yaml(vec![get_document(yaml)], None).unwrap(); + + assert_eq!(orders.len(), 2); + + let oracle_order = orders.get("oracle-order").unwrap(); + assert_eq!( + oracle_order.oracle_url.as_deref(), + Some("https://my-oracle.example.com/context") + ); + + let plain_order = orders.get("plain-order").unwrap(); + assert!(plain_order.oracle_url.is_none()); + } } diff --git a/crates/settings/src/yaml/context.rs b/crates/settings/src/yaml/context.rs index 06fdc69a51..03a319f309 100644 --- a/crates/settings/src/yaml/context.rs +++ b/crates/settings/src/yaml/context.rs @@ -437,6 +437,7 @@ mod tests { network: mock_network(), deployer: None, orderbook: None, + oracle_url: None, }) } @@ -453,6 +454,7 @@ mod tests { network: mock_network(), deployer: None, orderbook: None, + oracle_url: None, }) } @@ -587,6 +589,7 @@ mod tests { network: mock_network(), deployer: Some(mock_deployer()), orderbook: Some(mock_orderbook()), + oracle_url: None, }; context.add_order(Arc::new(order)); diff --git a/lib/rain.interpreter b/lib/rain.interpreter index c11023d69e..f860c58a55 160000 --- a/lib/rain.interpreter +++ b/lib/rain.interpreter @@ -1 +1 @@ -Subproject commit c11023d69e745fb4aa76366918dd89d672c68616 +Subproject commit f860c58a5575a33345ea14108ed7de6058933a31 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]}