From e9f2afbd884f826b3ab1d898e900d76b28249beb Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Fri, 13 Feb 2026 18:22:49 +0000 Subject: [PATCH 01/25] feat: add SignedContextOracleV1 to ParsedMeta and expose oracle_url on RaindexOrder - Add SignedContextOracleV1 variant to ParsedMeta enum - Add match arm for KnownMagic::SignedContextOracleV1 in parsing logic - Add oracle_url() wasm_bindgen getter on RaindexOrder - Depends on rain.metadata feat/signed-context-oracle-meta branch --- crates/common/src/parsed_meta.rs | 10 +++++++++- crates/common/src/raindex_client/orders.rs | 8 ++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/common/src/parsed_meta.rs b/crates/common/src/parsed_meta.rs index 38cb74af47..0068ad26ae 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}, + signed_context_oracle::SignedContextOracleV1, + }, 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), + SignedContextOracleV1(SignedContextOracleV1), } #[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::SignedContextOracleV1 => { + let oracle = SignedContextOracleV1::try_from(item.clone())?; + Ok(Some(ParsedMeta::SignedContextOracleV1(oracle))) + } // Filter out all other metadata types - they're not needed for the frontend _ => Ok(None), } diff --git a/crates/common/src/raindex_client/orders.rs b/crates/common/src/raindex_client/orders.rs index 664c72f97b..97c51e6ec2 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::SignedContextOracleV1(oracle) => Some(oracle.0), + _ => None, + }) + } #[wasm_bindgen(getter)] pub fn transaction(&self) -> Option { self.transaction.clone() From f0e1adf9163946db99f19a223f41257908959f41 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Sun, 15 Feb 2026 11:56:36 +0000 Subject: [PATCH 02/25] fix: add tests, non-wasm oracle_url(), address review feedback - Add 3 tests for SignedContextOracleV1 parsing (from_meta_item, parse_multiple, parse_from_bytes roundtrip) - Add oracle_url() to non-wasm impl block (mirrors wasm getter) - All 11 parsed_meta tests passing --- crates/common/src/parsed_meta.rs | 57 ++++++++++++++++++++++ crates/common/src/raindex_client/orders.rs | 7 +++ 2 files changed, 64 insertions(+) diff --git a/crates/common/src/parsed_meta.rs b/crates/common/src/parsed_meta.rs index 0068ad26ae..63d3756c87 100644 --- a/crates/common/src/parsed_meta.rs +++ b/crates/common/src/parsed_meta.rs @@ -206,4 +206,61 @@ 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_signed_context_oracle_v1() { + let oracle = SignedContextOracleV1::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::SignedContextOracleV1(parsed_oracle) => { + assert_eq!(parsed_oracle.url(), "https://oracle.example.com/prices/eth-usd"); + } + _ => panic!("Expected SignedContextOracleV1"), + } + } + + #[test] + fn test_parse_multiple_with_oracle() { + let source = get_default_dotrain_source(); + let oracle = SignedContextOracleV1::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::SignedContextOracleV1(parsed_oracle) => { + assert_eq!(parsed_oracle.url(), "https://oracle.example.com/feed"); + } + _ => panic!("Expected SignedContextOracleV1"), + } + } + + #[test] + fn test_parse_from_bytes_with_oracle() { + let oracle = + SignedContextOracleV1::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::SignedContextOracleV1(o) if o.url() == "https://oracle.example.com/prices/eth-usd") + ); + } } diff --git a/crates/common/src/raindex_client/orders.rs b/crates/common/src/raindex_client/orders.rs index 97c51e6ec2..b5625f4d1f 100644 --- a/crates/common/src/raindex_client/orders.rs +++ b/crates/common/src/raindex_client/orders.rs @@ -356,6 +356,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::SignedContextOracleV1(oracle) => Some(oracle.0), + _ => None, + }) + } pub fn transaction(&self) -> Option { self.transaction.clone() } From f2566dc92b649e3c1260c7babaa715198d5a3c9d Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Sun, 15 Feb 2026 12:03:46 +0000 Subject: [PATCH 03/25] chore: update rain.interpreter submodule to include SignedContextOracleV1 metadata Points rain.interpreter at feat/signed-context-oracle-meta-submodule branch which updates the rain.metadata submodule to feat/signed-context-oracle-meta. --- lib/rain.interpreter | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rain.interpreter b/lib/rain.interpreter index c11023d69e..ef58bce5de 160000 --- a/lib/rain.interpreter +++ b/lib/rain.interpreter @@ -1 +1 @@ -Subproject commit c11023d69e745fb4aa76366918dd89d672c68616 +Subproject commit ef58bce5deb4445f220e0ac25589e17a5bce7259 From d4339cbfd27c021f5783e8be29c054daa850bfc8 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 17 Feb 2026 13:45:41 +0000 Subject: [PATCH 04/25] =?UTF-8?q?rename=20SignedContextOracleV1=20?= =?UTF-8?q?=E2=86=92=20RaindexSignedContextOracleV1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches rename in rain.metadata#92. The metadata type is specific to the Raindex calculateOrderIO entrypoint. --- crates/common/src/parsed_meta.rs | 28 +++++++++++----------- crates/common/src/raindex_client/orders.rs | 4 ++-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/common/src/parsed_meta.rs b/crates/common/src/parsed_meta.rs index 63d3756c87..c271ba16a9 100644 --- a/crates/common/src/parsed_meta.rs +++ b/crates/common/src/parsed_meta.rs @@ -1,7 +1,7 @@ use rain_metadata::{ types::{ dotrain::{gui_state_v1::DotrainGuiStateV1, source_v1::DotrainSourceV1}, - signed_context_oracle::SignedContextOracleV1, + raindex_signed_context_oracle::RaindexSignedContextOracleV1, }, KnownMagic, RainMetaDocumentV1Item, }; @@ -16,7 +16,7 @@ use wasm_bindgen_utils::{impl_wasm_traits, prelude::*}; pub enum ParsedMeta { DotrainGuiStateV1(DotrainGuiStateV1), DotrainSourceV1(DotrainSourceV1), - SignedContextOracleV1(SignedContextOracleV1), + RaindexSignedContextOracleV1(RaindexSignedContextOracleV1), } #[cfg(target_family = "wasm")] impl_wasm_traits!(ParsedMeta); @@ -38,9 +38,9 @@ impl ParsedMeta { let source = DotrainSourceV1::try_from(item.clone())?; Ok(Some(ParsedMeta::DotrainSourceV1(source))) } - KnownMagic::SignedContextOracleV1 => { - let oracle = SignedContextOracleV1::try_from(item.clone())?; - Ok(Some(ParsedMeta::SignedContextOracleV1(oracle))) + 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), @@ -208,23 +208,23 @@ mod tests { } #[test] - fn test_from_meta_item_signed_context_oracle_v1() { - let oracle = SignedContextOracleV1::parse("https://oracle.example.com/prices/eth-usd") + 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::SignedContextOracleV1(parsed_oracle) => { + ParsedMeta::RaindexSignedContextOracleV1(parsed_oracle) => { assert_eq!(parsed_oracle.url(), "https://oracle.example.com/prices/eth-usd"); } - _ => panic!("Expected SignedContextOracleV1"), + _ => panic!("Expected RaindexSignedContextOracleV1"), } } #[test] fn test_parse_multiple_with_oracle() { let source = get_default_dotrain_source(); - let oracle = SignedContextOracleV1::parse("https://oracle.example.com/feed").unwrap(); + let oracle = RaindexSignedContextOracleV1::parse("https://oracle.example.com/feed").unwrap(); let items = vec![ RainMetaDocumentV1Item::from(source.clone()), @@ -242,17 +242,17 @@ mod tests { } match &results[1] { - ParsedMeta::SignedContextOracleV1(parsed_oracle) => { + ParsedMeta::RaindexSignedContextOracleV1(parsed_oracle) => { assert_eq!(parsed_oracle.url(), "https://oracle.example.com/feed"); } - _ => panic!("Expected SignedContextOracleV1"), + _ => panic!("Expected RaindexSignedContextOracleV1"), } } #[test] fn test_parse_from_bytes_with_oracle() { let oracle = - SignedContextOracleV1::parse("https://oracle.example.com/prices/eth-usd").unwrap(); + 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(); @@ -260,7 +260,7 @@ mod tests { let parsed = ParsedMeta::parse_from_bytes(&bytes).unwrap(); assert_eq!(parsed.len(), 1); assert!( - matches!(&parsed[0], ParsedMeta::SignedContextOracleV1(o) if o.url() == "https://oracle.example.com/prices/eth-usd") + matches!(&parsed[0], ParsedMeta::RaindexSignedContextOracleV1(o) if o.url() == "https://oracle.example.com/prices/eth-usd") ); } } diff --git a/crates/common/src/raindex_client/orders.rs b/crates/common/src/raindex_client/orders.rs index b5625f4d1f..082563b168 100644 --- a/crates/common/src/raindex_client/orders.rs +++ b/crates/common/src/raindex_client/orders.rs @@ -276,7 +276,7 @@ impl RaindexOrder { #[wasm_bindgen(getter = oracleUrl)] pub fn oracle_url(&self) -> Option { self.parsed_meta().into_iter().find_map(|meta| match meta { - ParsedMeta::SignedContextOracleV1(oracle) => Some(oracle.0), + ParsedMeta::RaindexSignedContextOracleV1(oracle) => Some(oracle.0), _ => None, }) } @@ -359,7 +359,7 @@ impl RaindexOrder { /// 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::SignedContextOracleV1(oracle) => Some(oracle.0), + ParsedMeta::RaindexSignedContextOracleV1(oracle) => Some(oracle.0), _ => None, }) } From 263d2332a38a1d943138657d93d9aaa40cb5d8aa Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 17 Feb 2026 16:35:25 +0000 Subject: [PATCH 05/25] chore: bump rain.interpreter to include metadata rename --- lib/rain.interpreter | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rain.interpreter b/lib/rain.interpreter index ef58bce5de..55a78efc8f 160000 --- a/lib/rain.interpreter +++ b/lib/rain.interpreter @@ -1 +1 @@ -Subproject commit ef58bce5deb4445f220e0ac25589e17a5bce7259 +Subproject commit 55a78efc8f1aabbd398264f34c1c949688470951 From faa6fdc9eb7498128f5ec81e319e53cbb239e5e9 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Sun, 22 Feb 2026 22:05:17 +0000 Subject: [PATCH 06/25] fix: cargo fmt parsed_meta.rs --- crates/common/src/parsed_meta.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/common/src/parsed_meta.rs b/crates/common/src/parsed_meta.rs index c271ba16a9..16d6949fc4 100644 --- a/crates/common/src/parsed_meta.rs +++ b/crates/common/src/parsed_meta.rs @@ -209,13 +209,17 @@ mod tests { #[test] fn test_from_meta_item_raindex_signed_context_oracle_v1() { - let oracle = RaindexSignedContextOracleV1::parse("https://oracle.example.com/prices/eth-usd") - .unwrap(); + 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"); + assert_eq!( + parsed_oracle.url(), + "https://oracle.example.com/prices/eth-usd" + ); } _ => panic!("Expected RaindexSignedContextOracleV1"), } @@ -224,7 +228,8 @@ mod tests { #[test] fn test_parse_multiple_with_oracle() { let source = get_default_dotrain_source(); - let oracle = RaindexSignedContextOracleV1::parse("https://oracle.example.com/feed").unwrap(); + let oracle = + RaindexSignedContextOracleV1::parse("https://oracle.example.com/feed").unwrap(); let items = vec![ RainMetaDocumentV1Item::from(source.clone()), @@ -252,7 +257,8 @@ mod tests { #[test] fn test_parse_from_bytes_with_oracle() { let oracle = - RaindexSignedContextOracleV1::parse("https://oracle.example.com/prices/eth-usd").unwrap(); + 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(); From 63e3282059cbb892152bb92cfcc17758c09aa989 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Sun, 22 Feb 2026 22:55:12 +0000 Subject: [PATCH 07/25] fix: update rain.interpreter submodule to include metadata fmt fix --- lib/rain.interpreter | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rain.interpreter b/lib/rain.interpreter index 55a78efc8f..f860c58a55 160000 --- a/lib/rain.interpreter +++ b/lib/rain.interpreter @@ -1 +1 @@ -Subproject commit 55a78efc8f1aabbd398264f34c1c949688470951 +Subproject commit f860c58a5575a33345ea14108ed7de6058933a31 From f29d775c4c547fd4521f6d9675ae2ea067b1fc0c Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Sun, 15 Feb 2026 13:10:32 +0000 Subject: [PATCH 08/25] 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 09/25] 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 10/25] 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 11/25] 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 12/25] 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 13/25] 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 14/25] 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 15/25] 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 16/25] 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 17/25] 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 18/25] 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 19/25] 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 59637803e9cf832b596a0b36ef64c5184da6b23e Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Sun, 15 Feb 2026 20:12:53 +0000 Subject: [PATCH 20/25] fix: prettier formatting --- .../lib/components/detail/OrderDetail.svelte | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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]} From b8e8baa5e45fe1c765e6db6d13b4370d81d5d344 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 16 Feb 2026 14:56:49 +0000 Subject: [PATCH 21/25] feat: parse oracle-url from YAML order config (Phase 6) Adds optional oracle-url field to OrderCfg, parsed from the YAML front matter orders section. When present, this URL identifies a signed context oracle server for the order. Changes: - Add oracle_url: Option to OrderCfg struct - Parse oracle-url via optional_string in YAML parsing - Add oracle-url to ALLOWED_ORDER_KEYS - Update Default and PartialEq impls - Add test for oracle-url parsing (present + absent) Spec: rainlanguage/specs#45 Chained on: #2459 (Phase 4) --- crates/common/src/add_order.rs | 4 ++ crates/settings/src/gui.rs | 1 + crates/settings/src/order.rs | 58 ++++++++++++++++++++++++++++- crates/settings/src/yaml/context.rs | 3 ++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/crates/common/src/add_order.rs b/crates/common/src/add_order.rs index 49fd1433c1..95cbb7ae6c 100644 --- a/crates/common/src/add_order.rs +++ b/crates/common/src/add_order.rs @@ -683,6 +683,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 +804,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 +968,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 +1310,7 @@ _ _: 16 52; network: network_arc.clone(), deployer: None, orderbook: None, + oracle_url: None, }; DeploymentCfg { document: default_document(), 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)); From 32668684d2e163fb2d3301cc9f6fc9c3b403e1b4 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 16 Feb 2026 15:59:29 +0000 Subject: [PATCH 22/25] feat: encode oracle-url into order metadata (Phase 7) When an order has oracle_url set in its config, new_from_deployment now creates a SignedContextOracleV1 meta item and includes it in the order's additional_meta. This means orders deployed with oracle-url in their YAML will have the oracle endpoint encoded in their onchain RainMetaDocumentV1, making it discoverable by takers and indexers (Phase 2 reads it back). Changes: - Import SignedContextOracleV1 in add_order.rs - In new_from_deployment: parse oracle_url, create meta item, append to additional_meta --- crates/common/src/add_order.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/common/src/add_order.rs b/crates/common/src/add_order.rs index 95cbb7ae6c..078a9b2eae 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::signed_context_oracle::SignedContextOracleV1, ContentEncoding, ContentLanguage, + ContentType, Error as RainMetaError, KnownMagic, RainMetaDocumentV1Item, }; use rain_metadata_bindings::MetaBoard::emitMetaCall; use rain_orderbook_app_settings::deployment::DeploymentCfg; @@ -130,6 +131,17 @@ impl AddOrderArgs { }); } + // If the order has an oracle URL, add a SignedContextOracleV1 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 = SignedContextOracleV1::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, From 3e96b73629f765bd35445b14b361a921f8bccc94 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 17 Feb 2026 13:46:37 +0000 Subject: [PATCH 23/25] propagate RaindexSignedContextOracleV1 rename to add_order.rs --- crates/common/src/add_order.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/common/src/add_order.rs b/crates/common/src/add_order.rs index 078a9b2eae..be4a9ac52a 100644 --- a/crates/common/src/add_order.rs +++ b/crates/common/src/add_order.rs @@ -23,7 +23,7 @@ use rain_interpreter_eval::{ use rain_interpreter_parser::{Parser2, ParserError, ParserV2}; use rain_metadata::{ types::dotrain::gui_state_v1::DotrainGuiStateV1, - types::signed_context_oracle::SignedContextOracleV1, ContentEncoding, ContentLanguage, + types::raindex_signed_context_oracle::RaindexSignedContextOracleV1, ContentEncoding, ContentLanguage, ContentType, Error as RainMetaError, KnownMagic, RainMetaDocumentV1Item, }; use rain_metadata_bindings::MetaBoard::emitMetaCall; @@ -131,11 +131,11 @@ impl AddOrderArgs { }); } - // If the order has an oracle URL, add a SignedContextOracleV1 meta item + // 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 = SignedContextOracleV1::parse(oracle_url) + let oracle = RaindexSignedContextOracleV1::parse(oracle_url) .map_err(AddOrderArgsError::RainMetaError)?; meta.push(oracle.to_meta_item()); } From 4d23ec94de24b242ab04a083b3def7aaa0f343c9 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 23 Feb 2026 01:44:09 +0000 Subject: [PATCH 24/25] fix: cargo fmt formatting --- crates/common/src/add_order.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/common/src/add_order.rs b/crates/common/src/add_order.rs index be4a9ac52a..0ba879503a 100644 --- a/crates/common/src/add_order.rs +++ b/crates/common/src/add_order.rs @@ -23,8 +23,8 @@ use rain_interpreter_eval::{ use rain_interpreter_parser::{Parser2, ParserError, ParserV2}; use rain_metadata::{ types::dotrain::gui_state_v1::DotrainGuiStateV1, - types::raindex_signed_context_oracle::RaindexSignedContextOracleV1, ContentEncoding, ContentLanguage, - ContentType, Error as RainMetaError, KnownMagic, RainMetaDocumentV1Item, + 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; @@ -139,7 +139,11 @@ impl AddOrderArgs { .map_err(AddOrderArgsError::RainMetaError)?; meta.push(oracle.to_meta_item()); } - if meta.is_empty() { None } else { Some(meta) } + if meta.is_empty() { + None + } else { + Some(meta) + } }; Ok(AddOrderArgs { From dd752c5936d7765563b92d4083a1231b704a6e73 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 23 Feb 2026 20:19:33 +0000 Subject: [PATCH 25/25] Add extractOracleUrl static method to RaindexOrder - Extracts oracle URL from raw meta bytes - CBOR decodes and finds RaindexSignedContextOracleV1 by magic number - Available in both Rust and WASM/JS APIs - Returns Option (URL if found, None otherwise) --- crates/common/src/raindex_client/orders.rs | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/common/src/raindex_client/orders.rs b/crates/common/src/raindex_client/orders.rs index 9fb8c7ff4f..2ccfab9fb3 100644 --- a/crates/common/src/raindex_client/orders.rs +++ b/crates/common/src/raindex_client/orders.rs @@ -308,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 { @@ -384,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(