diff --git a/crates/cli/src/error/mod.rs b/crates/cli/src/error/mod.rs index b8ecaa71..09852ccc 100644 --- a/crates/cli/src/error/mod.rs +++ b/crates/cli/src/error/mod.rs @@ -17,6 +17,7 @@ pub enum Error { UnsupportedNetwork, InflateError(String), InvalidInput(String), + InvalidUrl(String), Utf8Error(Utf8Error), FromUtf8Error(FromUtf8Error), ReqwestError(reqwest::Error), @@ -45,6 +46,7 @@ impl std::fmt::Display for Error { f.write_str("unexpected input size, must be 32 bytes or less") } Error::InvalidInput(v) => write!(f, "invalid input: {}", v), + Error::InvalidUrl(v) => write!(f, "invalid URL: {}", v), Error::ReqwestError(v) => write!(f, "{}", v), Error::InflateError(v) => write!(f, "{}", v), Error::Utf8Error(v) => write!(f, "{}", v), diff --git a/crates/cli/src/meta/magic.rs b/crates/cli/src/meta/magic.rs index a547562e..a5a14179 100644 --- a/crates/cli/src/meta/magic.rs +++ b/crates/cli/src/meta/magic.rs @@ -41,6 +41,10 @@ pub enum KnownMagic { DotrainSourceV1 = 0xffa15ef0fc437099, /// Dotrain instance meta v1 DotrainGuiStateV1 = 0xffda7b2fb167c286, + /// Signed context oracle endpoint v1 + /// Payload is raw UTF-8 bytes containing the oracle endpoint URL. + /// Used in order metadata to tell takers where to GET signed context data. + RaindexSignedContextOracleV1 = 0xff7a1507ba4419ca, } impl KnownMagic { @@ -71,6 +75,9 @@ impl TryFrom for KnownMagic { v if v == KnownMagic::RainlangSourceV1 as u64 => Ok(KnownMagic::RainlangSourceV1), v if v == KnownMagic::DotrainSourceV1 as u64 => Ok(KnownMagic::DotrainSourceV1), v if v == KnownMagic::DotrainGuiStateV1 as u64 => Ok(KnownMagic::DotrainGuiStateV1), + v if v == KnownMagic::RaindexSignedContextOracleV1 as u64 => { + Ok(KnownMagic::RaindexSignedContextOracleV1) + } _ => Err(crate::error::Error::UnknownMagic), } } @@ -176,4 +183,19 @@ mod tests { assert_eq!(hex::encode(magic_number_after_prefix), "ffda7b2fb167c286"); } + + #[test] + fn test_signed_context_oracle_v1() { + let magic_number = KnownMagic::RaindexSignedContextOracleV1; + let magic_number_after_prefix = magic_number.to_prefix_bytes(); + + assert_eq!(hex::encode(magic_number_after_prefix), "ff7a1507ba4419ca"); + } + + #[test] + fn test_signed_context_oracle_v1_roundtrip() { + let magic_number = KnownMagic::RaindexSignedContextOracleV1; + let from_u64 = KnownMagic::try_from(magic_number as u64).unwrap(); + assert_eq!(magic_number, from_u64); + } } diff --git a/crates/cli/src/meta/mod.rs b/crates/cli/src/meta/mod.rs index 9c58ef1e..7b07d88f 100644 --- a/crates/cli/src/meta/mod.rs +++ b/crates/cli/src/meta/mod.rs @@ -38,6 +38,7 @@ pub enum KnownMeta { AddressList, DotrainSourceV1, DotrainGuiStateV1, + RaindexSignedContextOracleV1, } impl TryFrom for KnownMeta { @@ -58,6 +59,7 @@ impl TryFrom for KnownMeta { Ok(KnownMeta::ExpressionDeployerV2BytecodeV1) } KnownMagic::RainlangSourceV1 => Ok(KnownMeta::RainlangSourceV1), + KnownMagic::RaindexSignedContextOracleV1 => Ok(KnownMeta::RaindexSignedContextOracleV1), _ => Err(Error::UnsupportedMeta), } } @@ -288,7 +290,8 @@ impl RainMetaDocumentV1Item { | KnownMagic::ExpressionDeployerV2BytecodeV1 | KnownMagic::DotrainSourceV1 | KnownMagic::DotrainGuiStateV1 - | KnownMagic::RainlangSourceV1 => T::try_from(self), + | KnownMagic::RainlangSourceV1 + | KnownMagic::RaindexSignedContextOracleV1 => T::try_from(self), _ => Err(Error::UnsupportedMeta)?, } } diff --git a/crates/cli/src/meta/types/mod.rs b/crates/cli/src/meta/types/mod.rs index 8cd50f86..640a970b 100644 --- a/crates/cli/src/meta/types/mod.rs +++ b/crates/cli/src/meta/types/mod.rs @@ -8,4 +8,5 @@ pub mod interpreter_caller; pub mod op; pub mod rainlang; pub mod rainlangsource; +pub mod raindex_signed_context_oracle; pub mod solidity_abi; diff --git a/crates/cli/src/meta/types/raindex_signed_context_oracle/mod.rs b/crates/cli/src/meta/types/raindex_signed_context_oracle/mod.rs new file mode 100644 index 00000000..bc317055 --- /dev/null +++ b/crates/cli/src/meta/types/raindex_signed_context_oracle/mod.rs @@ -0,0 +1,192 @@ +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::{ + error::Error, + meta::{ContentEncoding, ContentLanguage, ContentType, KnownMagic, RainMetaDocumentV1Item}, +}; + +#[cfg(target_family = "wasm")] +use wasm_bindgen_utils::{impl_wasm_traits, prelude::*}; + +/// Signed Context Oracle V1 meta. +/// +/// Contains a validated URL pointing to a GET endpoint that returns +/// signed context data for use in Rain order evaluation. +/// +/// The endpoint must return JSON: `{signer, context, signature}` mapping +/// directly to `SignedContextV1`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[cfg_attr(target_family = "wasm", derive(Tsify))] +pub struct RaindexSignedContextOracleV1(pub String); + +#[cfg(target_family = "wasm")] +impl_wasm_traits!(RaindexSignedContextOracleV1); + +impl RaindexSignedContextOracleV1 { + /// Create a new RaindexSignedContextOracleV1 from a URL. + /// Validates that the input is a well-formed URL. + pub fn new(url: Url) -> Self { + Self(url.to_string()) + } + + /// Parse and create a new RaindexSignedContextOracleV1 from a string. + /// Returns an error if the string is not a valid URL. + pub fn parse(url: &str) -> Result { + let parsed = Url::parse(url).map_err(|e| Error::InvalidUrl(e.to_string()))?; + Ok(Self::new(parsed)) + } + + /// Get the oracle URL as a string. + pub fn url(&self) -> &str { + &self.0 + } + + /// Get the oracle URL as a parsed Url. + pub fn parsed_url(&self) -> Result { + Url::parse(&self.0).map_err(|e| Error::InvalidUrl(e.to_string())) + } + + /// Encode this oracle descriptor as a `RainMetaDocumentV1Item`. + /// The payload is raw UTF-8 bytes of the URL string. + pub fn to_meta_item(&self) -> RainMetaDocumentV1Item { + RainMetaDocumentV1Item { + payload: serde_bytes::ByteBuf::from(self.0.as_bytes().to_vec()), + magic: KnownMagic::RaindexSignedContextOracleV1, + content_type: ContentType::None, + content_encoding: ContentEncoding::None, + content_language: ContentLanguage::None, + } + } + + /// Encode as a complete Rain meta document (with magic prefix). + pub fn cbor_encode(&self) -> Result, Error> { + RainMetaDocumentV1Item::cbor_encode_seq( + &vec![self.to_meta_item()], + KnownMagic::RainMetaDocumentV1, + ) + } + + /// Try to extract a `RaindexSignedContextOracleV1` from decoded meta items. + /// Returns `Ok(None)` if no oracle meta is found. + /// Returns `Err` if oracle meta is found but cannot be decoded. + pub fn find_in_items(items: &[RainMetaDocumentV1Item]) -> Result, Error> { + match items + .iter() + .find(|item| matches!(item.magic, KnownMagic::RaindexSignedContextOracleV1)) + { + Some(item) => Ok(Some(Self::try_from(item.clone())?)), + None => Ok(None), + } + } +} + +impl TryFrom for RaindexSignedContextOracleV1 { + type Error = Error; + fn try_from(value: RainMetaDocumentV1Item) -> Result { + if !matches!(value.magic, KnownMagic::RaindexSignedContextOracleV1) { + return Err(Error::UnsupportedMeta); + } + let bytes = value.unpack()?; + let url_str = String::from_utf8(bytes)?; + Self::parse(&url_str) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_roundtrip() { + let url = "https://oracle.example.com/prices/eth-usd"; + let oracle = RaindexSignedContextOracleV1::parse(url).unwrap(); + + // Encode to Rain meta document + let encoded = oracle.cbor_encode().unwrap(); + + // Should start with Rain meta document magic + assert!(encoded.starts_with(&KnownMagic::RainMetaDocumentV1.to_prefix_bytes())); + + // Decode back + let items = RainMetaDocumentV1Item::cbor_decode(&encoded).unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].magic, KnownMagic::RaindexSignedContextOracleV1); + + // Extract oracle + let decoded = RaindexSignedContextOracleV1::try_from(items[0].clone()).unwrap(); + assert_eq!(decoded.url(), url); + } + + #[test] + fn test_find_in_items() { + let url = "https://oracle.example.com/prices/eth-usd"; + let oracle = RaindexSignedContextOracleV1::parse(url).unwrap(); + let item = oracle.to_meta_item(); + + let found = RaindexSignedContextOracleV1::find_in_items(&[item]).unwrap().unwrap(); + assert_eq!(found.url(), url); + } + + #[test] + fn test_find_in_items_missing() { + let items: Vec = vec![]; + assert!(RaindexSignedContextOracleV1::find_in_items(&items).unwrap().is_none()); + } + + #[test] + fn test_find_in_items_decode_error() { + // Oracle magic but invalid payload (not valid UTF-8 URL) + let item = RainMetaDocumentV1Item { + payload: serde_bytes::ByteBuf::from(vec![0xFF, 0xFE]), + magic: KnownMagic::RaindexSignedContextOracleV1, + content_type: ContentType::None, + content_encoding: ContentEncoding::None, + content_language: ContentLanguage::None, + }; + assert!(RaindexSignedContextOracleV1::find_in_items(&[item]).is_err()); + } + + #[test] + fn test_new_with_url() { + let url = Url::parse("https://example.com/feed").unwrap(); + let oracle = RaindexSignedContextOracleV1::new(url); + assert_eq!(oracle.url(), "https://example.com/feed"); + } + + #[test] + fn test_parse_valid_url() { + let oracle = RaindexSignedContextOracleV1::parse("https://example.com/feed").unwrap(); + assert_eq!(oracle.url(), "https://example.com/feed"); + } + + #[test] + fn test_parse_invalid_url() { + assert!(RaindexSignedContextOracleV1::parse("not a url").is_err()); + } + + #[test] + fn test_parse_empty_url() { + assert!(RaindexSignedContextOracleV1::parse("").is_err()); + } + + #[test] + fn test_wrong_magic_fails() { + let item = RainMetaDocumentV1Item { + payload: serde_bytes::ByteBuf::from(b"https://example.com".to_vec()), + magic: KnownMagic::DotrainSourceV1, + content_type: ContentType::None, + content_encoding: ContentEncoding::None, + content_language: ContentLanguage::None, + }; + assert!(RaindexSignedContextOracleV1::try_from(item).is_err()); + } + + #[test] + fn test_parsed_url() { + let oracle = RaindexSignedContextOracleV1::parse("https://example.com/feed?pair=eth-usd").unwrap(); + let parsed = oracle.parsed_url().unwrap(); + assert_eq!(parsed.host_str(), Some("example.com")); + assert_eq!(parsed.path(), "/feed"); + } +} diff --git a/foundry.lock b/foundry.lock index eb03d769..e5db8921 100644 --- a/foundry.lock +++ b/foundry.lock @@ -1,5 +1,8 @@ { "lib/forge-std": { "rev": "1801b0541f4fda118a10798fd3486bb7051c5dd6" + }, + "lib/rain.deploy": { + "rev": "e419a46e2a1317a63639ac13fc5a22d7e967fbef" } } \ No newline at end of file