From a4392aca5903f7629350bf1da24a9cf478051a4e Mon Sep 17 00:00:00 2001 From: e-desouza Date: Wed, 1 Apr 2026 22:36:54 +0000 Subject: [PATCH 1/8] feat: add PriceData type for XLS-47 price oracle support Add PriceData struct using the serde_with_tag! macro to support the XLS-47 PriceOracle amendment. Also adds OracleSet and OracleDelete variants to the TransactionType enum and module declarations for the oracle transaction types. --- src/models/transactions/mod.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/models/transactions/mod.rs b/src/models/transactions/mod.rs index 167d56c8..0907263d 100644 --- a/src/models/transactions/mod.rs +++ b/src/models/transactions/mod.rs @@ -22,6 +22,8 @@ pub mod nftoken_create_offer; pub mod nftoken_mint; pub mod offer_cancel; pub mod offer_create; +pub mod oracle_delete; +pub mod oracle_set; pub mod payment; pub mod payment_channel_claim; pub mod payment_channel_create; @@ -87,6 +89,8 @@ pub enum TransactionType { NFTokenMint, OfferCancel, OfferCreate, + OracleDelete, + OracleSet, #[default] Payment, PaymentChannelClaim, @@ -572,6 +576,20 @@ pub struct Signer { } } +serde_with_tag! { +/// Represents a single price data entry in an Oracle's PriceDataSeries. +/// +/// See OracleSet: +/// `` +#[derive(Debug, PartialEq, Eq, Clone, Default)] +pub struct PriceData { + pub base_asset: Option, + pub quote_asset: Option, + pub asset_price: Option, + pub scale: Option, +} +} + /// Standard functions for transactions. pub trait Transaction<'a, T> where From 462167b20df6ee8079c81d1a052764d990fab59a Mon Sep 17 00:00:00 2001 From: e-desouza Date: Wed, 1 Apr 2026 22:37:01 +0000 Subject: [PATCH 2/8] feat: add OracleSet transaction type (XLS-47) Implement the OracleSet transaction for creating and updating price oracle ledger entries. Includes new() constructor, builder methods for oracle-specific fields, and comprehensive unit tests covering serialization round-trips, builder patterns, and edge cases. --- src/models/transactions/oracle_set.rs | 435 ++++++++++++++++++++++++++ 1 file changed, 435 insertions(+) create mode 100644 src/models/transactions/oracle_set.rs diff --git a/src/models/transactions/oracle_set.rs b/src/models/transactions/oracle_set.rs new file mode 100644 index 00000000..8711c96f --- /dev/null +++ b/src/models/transactions/oracle_set.rs @@ -0,0 +1,435 @@ +use alloc::borrow::Cow; +use alloc::vec::Vec; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::models::amount::XRPAmount; +use crate::models::transactions::{Memo, PriceData, Signer, Transaction, TransactionType}; +use crate::models::{FlagCollection, Model, NoFlags, XRPLModelResult}; + +use super::{CommonFields, CommonTransactionBuilder}; + +/// An OracleSet transaction creates or updates an Oracle ledger entry. +/// +/// See OracleSet: +/// `` +#[skip_serializing_none] +#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct OracleSet<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// A unique identifier of the price oracle for the account. + #[serde(rename = "OracleDocumentID")] + pub oracle_document_id: Option, + /// An arbitrary value that identifies an oracle provider, such as + /// Chainlink, Band, or DIA. This field is a string, up to 256 ASCII + /// hex encoded characters (128 bytes). + pub provider: Option>, + /// An optional Universal Resource Identifier to reference price data + /// off-chain. This field is limited to 256 bytes. + #[serde(rename = "URI")] + pub uri: Option>, + /// Describes the type of asset, such as "currency", "commodity", or + /// "NFT". This field is a string, up to 16 ASCII hex encoded characters + /// (8 bytes). + pub asset_class: Option>, + /// The time the data was last updated, represented in the ripple epoch. + pub last_update_time: Option, + /// An array of up to 10 PriceData objects, each representing one + /// price data entry. + pub price_data_series: Option>, +} + +impl Model for OracleSet<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + Ok(()) + } +} + +impl<'a> Transaction<'a, NoFlags> for OracleSet<'a> { + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } + + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + self.common_fields.get_common_fields() + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + self.common_fields.get_mut_common_fields() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for OracleSet<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> OracleSet<'a> { + pub fn new( + account: Cow<'a, str>, + account_txn_id: Option>, + fee: Option>, + last_ledger_sequence: Option, + memos: Option>, + sequence: Option, + signers: Option>, + source_tag: Option, + ticket_sequence: Option, + oracle_document_id: Option, + provider: Option>, + uri: Option>, + asset_class: Option>, + last_update_time: Option, + price_data_series: Option>, + ) -> Self { + Self { + common_fields: CommonFields::new( + account, + TransactionType::OracleSet, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + oracle_document_id, + provider, + uri, + asset_class, + last_update_time, + price_data_series, + } + } + + /// Set the oracle document ID + pub fn with_oracle_document_id(mut self, id: u32) -> Self { + self.oracle_document_id = Some(id); + self + } + + /// Set the provider + pub fn with_provider(mut self, provider: Cow<'a, str>) -> Self { + self.provider = Some(provider); + self + } + + /// Set the URI + pub fn with_uri(mut self, uri: Cow<'a, str>) -> Self { + self.uri = Some(uri); + self + } + + /// Set the asset class + pub fn with_asset_class(mut self, asset_class: Cow<'a, str>) -> Self { + self.asset_class = Some(asset_class); + self + } + + /// Set the last update time + pub fn with_last_update_time(mut self, time: u32) -> Self { + self.last_update_time = Some(time); + self + } + + /// Set the price data series + pub fn with_price_data_series(mut self, series: Vec) -> Self { + self.price_data_series = Some(series); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::string::ToString; + use alloc::vec; + + #[test] + fn test_serde() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + fee: Some("12".into()), + sequence: Some(391), + signing_pub_key: Some("".into()), + ..Default::default() + }, + oracle_document_id: Some(1), + provider: Some("chainlink".into()), + uri: Some("https://example.com/oracle1".into()), + asset_class: Some("63757272656E6379".into()), + last_update_time: Some(743609014), + price_data_series: Some(vec![PriceData { + base_asset: Some("XRP".to_string()), + quote_asset: Some("USD".to_string()), + asset_price: Some("740".to_string()), + scale: Some(1), + }]), + }; + + let serialized = serde_json::to_string(&oracle_set).unwrap(); + let deserialized: OracleSet = serde_json::from_str(&serialized).unwrap(); + assert_eq!(oracle_set, deserialized); + } + + #[test] + fn test_builder_pattern() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_oracle_document_id(1) + .with_provider("chainlink".into()) + .with_uri("https://example.com".into()) + .with_asset_class("63757272656E6379".into()) + .with_last_update_time(743609014) + .with_fee("12".into()) + .with_sequence(100) + .with_last_ledger_sequence(596447) + .with_source_tag(42); + + assert_eq!(oracle_set.oracle_document_id, Some(1)); + assert_eq!(oracle_set.provider.as_deref(), Some("chainlink")); + assert_eq!(oracle_set.uri.as_deref(), Some("https://example.com")); + assert_eq!(oracle_set.asset_class.as_deref(), Some("63757272656E6379")); + assert_eq!(oracle_set.last_update_time, Some(743609014)); + assert_eq!(oracle_set.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(oracle_set.common_fields.sequence, Some(100)); + assert_eq!(oracle_set.common_fields.last_ledger_sequence, Some(596447)); + assert_eq!(oracle_set.common_fields.source_tag, Some(42)); + } + + #[test] + fn test_default() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + }; + + assert_eq!( + oracle_set.common_fields.account, + "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" + ); + assert_eq!( + oracle_set.common_fields.transaction_type, + TransactionType::OracleSet + ); + assert!(oracle_set.oracle_document_id.is_none()); + assert!(oracle_set.provider.is_none()); + assert!(oracle_set.uri.is_none()); + assert!(oracle_set.asset_class.is_none()); + assert!(oracle_set.last_update_time.is_none()); + assert!(oracle_set.price_data_series.is_none()); + } + + #[test] + fn test_with_price_data() { + let price_data = vec![ + PriceData { + base_asset: Some("XRP".to_string()), + quote_asset: Some("USD".to_string()), + asset_price: Some("740".to_string()), + scale: Some(1), + }, + PriceData { + base_asset: Some("BTC".to_string()), + quote_asset: Some("USD".to_string()), + asset_price: Some("2600000".to_string()), + scale: Some(2), + }, + ]; + + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(price_data.clone()); + + let series = oracle_set.price_data_series.as_ref().unwrap(); + assert_eq!(series.len(), 2); + assert_eq!(series[0].base_asset.as_deref(), Some("XRP")); + assert_eq!(series[0].quote_asset.as_deref(), Some("USD")); + assert_eq!(series[0].asset_price.as_deref(), Some("740")); + assert_eq!(series[0].scale, Some(1)); + assert_eq!(series[1].base_asset.as_deref(), Some("BTC")); + } + + #[test] + fn test_minimal() { + let oracle_set = OracleSet::new( + "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + None, + None, + None, + None, + None, + None, + None, + None, + Some(1), + None, + None, + None, + None, + None, + ); + + assert_eq!( + oracle_set.common_fields.account, + "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" + ); + assert_eq!( + oracle_set.common_fields.transaction_type, + TransactionType::OracleSet + ); + assert_eq!(oracle_set.oracle_document_id, Some(1)); + } + + #[test] + fn test_new_constructor() { + let price_data = vec![PriceData { + base_asset: Some("XRP".to_string()), + quote_asset: Some("USD".to_string()), + asset_price: Some("740".to_string()), + scale: Some(1), + }]; + + let oracle_set = OracleSet::new( + "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + None, + Some("12".into()), + Some(596447), + None, + Some(391), + None, + None, + None, + Some(1), + Some("chainlink".into()), + Some("https://example.com/oracle1".into()), + Some("63757272656E6379".into()), + Some(743609014), + Some(price_data), + ); + + assert_eq!( + oracle_set.common_fields.transaction_type, + TransactionType::OracleSet + ); + assert_eq!(oracle_set.common_fields.fee, Some("12".into())); + assert_eq!(oracle_set.common_fields.sequence, Some(391)); + assert_eq!(oracle_set.oracle_document_id, Some(1)); + assert_eq!(oracle_set.provider.as_deref(), Some("chainlink")); + assert_eq!(oracle_set.last_update_time, Some(743609014)); + assert_eq!(oracle_set.price_data_series.as_ref().unwrap().len(), 1); + } + + #[test] + fn test_transaction_type() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + }; + + assert_eq!( + *oracle_set.get_transaction_type(), + TransactionType::OracleSet + ); + } + + #[test] + fn test_with_memos() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_oracle_document_id(1) + .with_memo(Memo { + memo_data: Some("oracle update".into()), + memo_format: None, + memo_type: Some("text".into()), + }); + + assert_eq!(oracle_set.common_fields.memos.as_ref().unwrap().len(), 1); + } + + #[test] + fn test_empty_price_data_series() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![]); + + assert_eq!(oracle_set.price_data_series.as_ref().unwrap().len(), 0); + } + + #[test] + fn test_price_data_partial_fields() { + let price_data = PriceData { + base_asset: Some("XRP".to_string()), + quote_asset: None, + asset_price: None, + scale: None, + }; + + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![price_data]); + + let series = oracle_set.price_data_series.as_ref().unwrap(); + assert_eq!(series[0].base_asset.as_deref(), Some("XRP")); + assert!(series[0].quote_asset.is_none()); + assert!(series[0].asset_price.is_none()); + assert!(series[0].scale.is_none()); + } +} From 85ab9ee0e6b6835e829bfa89d6ae6a5875d58124 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Wed, 1 Apr 2026 22:37:06 +0000 Subject: [PATCH 3/8] feat: add OracleDelete transaction type (XLS-47) Implement the OracleDelete transaction for removing price oracle ledger entries. Includes new() constructor and unit tests covering serialization, builder patterns, and boundary values. --- src/models/transactions/oracle_delete.rs | 277 +++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 src/models/transactions/oracle_delete.rs diff --git a/src/models/transactions/oracle_delete.rs b/src/models/transactions/oracle_delete.rs new file mode 100644 index 00000000..f2d08d13 --- /dev/null +++ b/src/models/transactions/oracle_delete.rs @@ -0,0 +1,277 @@ +use alloc::borrow::Cow; +use alloc::vec::Vec; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::models::amount::XRPAmount; +use crate::models::transactions::{Memo, Signer, Transaction, TransactionType}; +use crate::models::{FlagCollection, Model, NoFlags, XRPLModelResult}; + +use super::{CommonFields, CommonTransactionBuilder}; + +/// An OracleDelete transaction removes an Oracle ledger entry. +/// +/// See OracleDelete: +/// `` +#[skip_serializing_none] +#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct OracleDelete<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// A unique identifier of the price oracle for the account. + #[serde(rename = "OracleDocumentID")] + pub oracle_document_id: u32, +} + +impl Model for OracleDelete<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + Ok(()) + } +} + +impl<'a> Transaction<'a, NoFlags> for OracleDelete<'a> { + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } + + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + self.common_fields.get_common_fields() + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + self.common_fields.get_mut_common_fields() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for OracleDelete<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> OracleDelete<'a> { + pub fn new( + account: Cow<'a, str>, + account_txn_id: Option>, + fee: Option>, + last_ledger_sequence: Option, + memos: Option>, + sequence: Option, + signers: Option>, + source_tag: Option, + ticket_sequence: Option, + oracle_document_id: u32, + ) -> Self { + Self { + common_fields: CommonFields::new( + account, + TransactionType::OracleDelete, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + oracle_document_id, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde() { + let oracle_delete = OracleDelete { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleDelete, + fee: Some("12".into()), + sequence: Some(391), + signing_pub_key: Some("".into()), + ..Default::default() + }, + oracle_document_id: 1, + }; + + let default_json_str = r#"{"Account":"rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW","TransactionType":"OracleDelete","Fee":"12","Flags":0,"Sequence":391,"SigningPubKey":"","OracleDocumentID":1}"#; + + let serialized_string = serde_json::to_string(&oracle_delete).unwrap(); + let serialized_value = serde_json::to_value(&serialized_string).unwrap(); + let default_json_value = serde_json::to_value(default_json_str).unwrap(); + assert_eq!(serialized_value, default_json_value); + + let deserialized: OracleDelete = serde_json::from_str(default_json_str).unwrap(); + assert_eq!(oracle_delete, deserialized); + } + + #[test] + fn test_builder_pattern() { + let oracle_delete = OracleDelete { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleDelete, + ..Default::default() + }, + oracle_document_id: 1, + } + .with_fee("12".into()) + .with_sequence(391) + .with_last_ledger_sequence(596447) + .with_source_tag(42) + .with_memo(Memo { + memo_data: Some("deleting oracle".into()), + memo_format: None, + memo_type: Some("text".into()), + }); + + assert_eq!(oracle_delete.oracle_document_id, 1); + assert_eq!(oracle_delete.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(oracle_delete.common_fields.sequence, Some(391)); + assert_eq!( + oracle_delete.common_fields.last_ledger_sequence, + Some(596447) + ); + assert_eq!(oracle_delete.common_fields.source_tag, Some(42)); + assert_eq!(oracle_delete.common_fields.memos.as_ref().unwrap().len(), 1); + } + + #[test] + fn test_default() { + let oracle_delete = OracleDelete { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleDelete, + ..Default::default() + }, + oracle_document_id: 5, + }; + + assert_eq!( + oracle_delete.common_fields.account, + "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW" + ); + assert_eq!( + oracle_delete.common_fields.transaction_type, + TransactionType::OracleDelete + ); + assert_eq!(oracle_delete.oracle_document_id, 5); + assert!(oracle_delete.common_fields.fee.is_none()); + assert!(oracle_delete.common_fields.sequence.is_none()); + } + + #[test] + fn test_new_constructor() { + let oracle_delete = OracleDelete::new( + "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + None, + Some("12".into()), + Some(596447), + None, + Some(391), + None, + None, + None, + 1, + ); + + assert_eq!( + oracle_delete.common_fields.transaction_type, + TransactionType::OracleDelete + ); + assert_eq!(oracle_delete.common_fields.fee, Some("12".into())); + assert_eq!(oracle_delete.common_fields.sequence, Some(391)); + assert_eq!( + oracle_delete.common_fields.last_ledger_sequence, + Some(596447) + ); + assert_eq!(oracle_delete.oracle_document_id, 1); + } + + #[test] + fn test_transaction_type() { + let oracle_delete = OracleDelete { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleDelete, + ..Default::default() + }, + oracle_document_id: 0, + }; + + assert_eq!( + *oracle_delete.get_transaction_type(), + TransactionType::OracleDelete + ); + } + + #[test] + fn test_ticket_sequence() { + let oracle_delete = OracleDelete { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleDelete, + ..Default::default() + }, + oracle_document_id: 3, + } + .with_ticket_sequence(54321) + .with_fee("12".into()); + + assert_eq!(oracle_delete.common_fields.ticket_sequence, Some(54321)); + assert!(oracle_delete.common_fields.sequence.is_none()); + } + + #[test] + fn test_zero_document_id() { + let oracle_delete = OracleDelete::new( + "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + None, + None, + None, + None, + None, + None, + None, + None, + 0, + ); + + assert_eq!(oracle_delete.oracle_document_id, 0); + } + + #[test] + fn test_max_document_id() { + let oracle_delete = OracleDelete::new( + "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + None, + None, + None, + None, + None, + None, + None, + None, + u32::MAX, + ); + + assert_eq!(oracle_delete.oracle_document_id, u32::MAX); + } +} From 2de85dd1f9135739d07568bcf5ad5b2351171040 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Wed, 1 Apr 2026 22:37:14 +0000 Subject: [PATCH 4/8] feat: add Oracle ledger entry type (XLS-47) Add the Oracle ledger object representing on-ledger price oracle state. Includes Oracle variant in LedgerEntryType (0x0080) and LedgerEntry enums, new() constructor, and serde tests. --- src/models/ledger/objects/mod.rs | 4 + src/models/ledger/objects/oracle.rs | 211 ++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 src/models/ledger/objects/oracle.rs diff --git a/src/models/ledger/objects/mod.rs b/src/models/ledger/objects/mod.rs index 45fa941f..3c793438 100644 --- a/src/models/ledger/objects/mod.rs +++ b/src/models/ledger/objects/mod.rs @@ -12,6 +12,7 @@ pub mod negative_unl; pub mod nftoken_offer; pub mod nftoken_page; pub mod offer; +pub mod oracle; pub mod pay_channel; pub mod ripple_state; pub mod signer_list; @@ -34,6 +35,7 @@ use negative_unl::NegativeUNL; use nftoken_offer::NFTokenOffer; use nftoken_page::NFTokenPage; use offer::Offer; +use oracle::Oracle; use pay_channel::PayChannel; use ripple_state::RippleState; use signer_list::SignerList; @@ -66,6 +68,7 @@ pub enum LedgerEntryType { NFTokenOffer = 0x0037, NFTokenPage = 0x0050, Offer = 0x006F, + Oracle = 0x0080, PayChannel = 0x0078, RippleState = 0x0072, SignerList = 0x0053, @@ -90,6 +93,7 @@ pub enum LedgerEntry<'a> { NFTokenOffer(NFTokenOffer<'a>), NFTokenPage(NFTokenPage<'a>), Offer(Offer<'a>), + Oracle(Oracle<'a>), PayChannel(PayChannel<'a>), RippleState(RippleState<'a>), SignerList(SignerList<'a>), diff --git a/src/models/ledger/objects/oracle.rs b/src/models/ledger/objects/oracle.rs new file mode 100644 index 00000000..8a648b94 --- /dev/null +++ b/src/models/ledger/objects/oracle.rs @@ -0,0 +1,211 @@ +use crate::models::ledger::objects::LedgerEntryType; +use crate::models::transactions::PriceData; +use crate::models::{FlagCollection, Model, NoFlags}; +use alloc::borrow::Cow; +use alloc::vec::Vec; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use super::{CommonFields, LedgerObject}; + +/// The Oracle ledger entry holds data associated with a single price oracle object. +/// +/// See Oracle: +/// `` +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct Oracle<'a> { + /// The base fields for all ledger object models. + /// + /// See Ledger Object Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The XRPL account with update and delete privileges for the oracle. + pub owner: Cow<'a, str>, + /// An arbitrary value that identifies an oracle provider. + pub provider: Cow<'a, str>, + /// Describes the type of asset, such as "currency", "commodity", or "NFT". + pub asset_class: Option>, + /// An array of up to 10 PriceData objects, representing the price information. + pub price_data_series: Option>, + /// The time the data was last updated, represented in the ripple epoch. + pub last_update_time: u32, + /// An optional Universal Resource Identifier to reference price data off-chain. + #[serde(rename = "URI")] + pub uri: Option>, + /// A hint indicating which page of the owner directory links to this entry. + pub owner_node: Option>, + /// The identifying hash of the transaction that most recently modified this entry. + #[serde(rename = "PreviousTxnID")] + pub previous_txn_id: Cow<'a, str>, + /// The index of the ledger that contains the transaction that most recently + /// modified this entry. + pub previous_txn_lgr_seq: u32, +} + +impl Model for Oracle<'_> {} + +impl<'a> LedgerObject for Oracle<'a> { + fn get_ledger_entry_type(&self) -> LedgerEntryType { + self.common_fields.get_ledger_entry_type() + } +} + +impl<'a> Oracle<'a> { + pub fn new( + index: Option>, + ledger_index: Option>, + owner: Cow<'a, str>, + provider: Cow<'a, str>, + asset_class: Option>, + price_data_series: Option>, + last_update_time: u32, + uri: Option>, + owner_node: Option>, + previous_txn_id: Cow<'a, str>, + previous_txn_lgr_seq: u32, + ) -> Self { + Self { + common_fields: CommonFields { + flags: FlagCollection::default(), + ledger_entry_type: LedgerEntryType::Oracle, + index, + ledger_index, + }, + owner, + provider, + asset_class, + price_data_series, + last_update_time, + uri, + owner_node, + previous_txn_id, + previous_txn_lgr_seq, + } + } +} + +#[cfg(test)] +mod test_serde { + use super::*; + use crate::models::transactions::PriceData; + use alloc::borrow::Cow; + use alloc::string::ToString; + use alloc::vec; + + #[test] + fn test_serialize() { + let oracle = Oracle::new( + Some(Cow::from("ForTest")), + None, + Cow::from("rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"), + Cow::from("chainlink"), + Some(Cow::from("63757272656E6379")), + Some(vec![PriceData { + base_asset: Some("XRP".to_string()), + quote_asset: Some("USD".to_string()), + asset_price: Some("740".to_string()), + scale: Some(1), + }]), + 743609014, + Some(Cow::from("https://example.com/oracle1")), + Some(Cow::from("0")), + Cow::from("ABC123DEF456"), + 12345678, + ); + + let serialized = serde_json::to_string(&oracle).unwrap(); + let deserialized: Oracle = serde_json::from_str(&serialized).unwrap(); + assert_eq!(oracle, deserialized); + } + + #[test] + fn test_new_minimal() { + let oracle = Oracle::new( + None, + None, + Cow::from("rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"), + Cow::from("provider1"), + None, + None, + 743609014, + None, + None, + Cow::from("ABC123"), + 100, + ); + + assert_eq!(oracle.owner, "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"); + assert_eq!(oracle.provider, "provider1"); + assert!(oracle.asset_class.is_none()); + assert!(oracle.price_data_series.is_none()); + assert_eq!(oracle.last_update_time, 743609014); + assert!(oracle.uri.is_none()); + assert!(oracle.owner_node.is_none()); + assert_eq!(oracle.previous_txn_id, "ABC123"); + assert_eq!(oracle.previous_txn_lgr_seq, 100); + } + + #[test] + fn test_ledger_entry_type() { + let oracle = Oracle::new( + None, + None, + Cow::from("rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"), + Cow::from("provider1"), + None, + None, + 0, + None, + None, + Cow::from("ABC123"), + 0, + ); + + assert_eq!(oracle.get_ledger_entry_type(), LedgerEntryType::Oracle); + } + + #[test] + fn test_with_multiple_price_data() { + let oracle = Oracle::new( + Some(Cow::from("TestIndex")), + None, + Cow::from("rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"), + Cow::from("chainlink"), + Some(Cow::from("63757272656E6379")), + Some(vec![ + PriceData { + base_asset: Some("XRP".to_string()), + quote_asset: Some("USD".to_string()), + asset_price: Some("740".to_string()), + scale: Some(1), + }, + PriceData { + base_asset: Some("BTC".to_string()), + quote_asset: Some("USD".to_string()), + asset_price: Some("2600000".to_string()), + scale: Some(2), + }, + PriceData { + base_asset: Some("ETH".to_string()), + quote_asset: Some("USD".to_string()), + asset_price: Some("160000".to_string()), + scale: Some(2), + }, + ]), + 743609014, + Some(Cow::from("https://example.com")), + Some(Cow::from("0")), + Cow::from("DEF789"), + 99999, + ); + + let series = oracle.price_data_series.as_ref().unwrap(); + assert_eq!(series.len(), 3); + assert_eq!(series[0].base_asset.as_deref(), Some("XRP")); + assert_eq!(series[1].base_asset.as_deref(), Some("BTC")); + assert_eq!(series[2].base_asset.as_deref(), Some("ETH")); + } +} From 972f986fe474d359ccd41f69c898f1a807ad3ccd Mon Sep 17 00:00:00 2001 From: e-desouza Date: Wed, 1 Apr 2026 22:37:26 +0000 Subject: [PATCH 5/8] test: add integration tests for price oracle types Add integration test stubs for OracleSet and OracleDelete transactions. These tests validate type construction and serde round-trips without requiring a live rippled instance (gated behind the integration feature). --- tests/transactions/mod.rs | 2 + tests/transactions/oracle_delete.rs | 51 ++++++++++++++++++++++ tests/transactions/oracle_set.rs | 67 +++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 tests/transactions/oracle_delete.rs create mode 100644 tests/transactions/oracle_set.rs diff --git a/tests/transactions/mod.rs b/tests/transactions/mod.rs index c0e13b8d..079ea933 100644 --- a/tests/transactions/mod.rs +++ b/tests/transactions/mod.rs @@ -20,6 +20,8 @@ pub mod nftoken_create_offer; pub mod nftoken_mint; pub mod offer_cancel; pub mod offer_create; +pub mod oracle_delete; +pub mod oracle_set; pub mod payment; pub mod payment_channel_claim; pub mod payment_channel_create; diff --git a/tests/transactions/oracle_delete.rs b/tests/transactions/oracle_delete.rs new file mode 100644 index 00000000..60187a8a --- /dev/null +++ b/tests/transactions/oracle_delete.rs @@ -0,0 +1,51 @@ +// xrpl.js reference: n/a (XLS-47 price oracle support) +// +// Scenarios: +// - base: construct and validate an OracleDelete transaction +// +// NOTE: OracleDelete requires a live rippled with amendment support for price +// oracles (XLS-47). These tests validate type construction and serialization +// without submitting to a network. + +use xrpl::models::transactions::oracle_delete::OracleDelete; +use xrpl::models::transactions::{CommonFields, TransactionType}; + +#[test] +fn test_oracle_delete_construction() { + let oracle_delete = OracleDelete { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleDelete, + fee: Some("12".into()), + sequence: Some(391), + ..Default::default() + }, + oracle_document_id: 1, + }; + + assert_eq!( + oracle_delete.common_fields.transaction_type, + TransactionType::OracleDelete + ); + assert_eq!(oracle_delete.oracle_document_id, 1); +} + +#[test] +fn test_oracle_delete_serde_roundtrip() { + let oracle_delete = OracleDelete::new( + "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + None, + Some("12".into()), + None, + None, + Some(391), + None, + None, + None, + 1, + ); + + let json = serde_json::to_string(&oracle_delete).unwrap(); + let deserialized: OracleDelete = serde_json::from_str(&json).unwrap(); + assert_eq!(oracle_delete, deserialized); +} diff --git a/tests/transactions/oracle_set.rs b/tests/transactions/oracle_set.rs new file mode 100644 index 00000000..39e37634 --- /dev/null +++ b/tests/transactions/oracle_set.rs @@ -0,0 +1,67 @@ +// xrpl.js reference: n/a (XLS-47 price oracle support) +// +// Scenarios: +// - base: construct and validate an OracleSet transaction +// +// NOTE: OracleSet requires a live rippled with amendment support for price +// oracles (XLS-47). These tests validate type construction and serialization +// without submitting to a network. + +use xrpl::models::transactions::oracle_set::OracleSet; +use xrpl::models::transactions::{CommonFields, PriceData, TransactionType}; + +#[test] +fn test_oracle_set_construction() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + fee: Some("12".into()), + sequence: Some(391), + ..Default::default() + }, + oracle_document_id: Some(1), + provider: Some("chainlink".into()), + uri: Some("https://example.com/oracle".into()), + asset_class: Some("63757272656E6379".into()), + last_update_time: Some(743609014), + price_data_series: Some(vec![PriceData { + base_asset: Some("XRP".into()), + quote_asset: Some("USD".into()), + asset_price: Some("740".into()), + scale: Some(1), + }]), + }; + + assert_eq!( + oracle_set.common_fields.transaction_type, + TransactionType::OracleSet + ); + assert_eq!(oracle_set.oracle_document_id, Some(1)); + assert_eq!(oracle_set.price_data_series.as_ref().unwrap().len(), 1); +} + +#[test] +fn test_oracle_set_serde_roundtrip() { + let oracle_set = OracleSet::new( + "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + None, + Some("12".into()), + None, + None, + Some(391), + None, + None, + None, + Some(1), + Some("provider".into()), + None, + None, + Some(743609014), + None, + ); + + let json = serde_json::to_string(&oracle_set).unwrap(); + let deserialized: OracleSet = serde_json::from_str(&json).unwrap(); + assert_eq!(oracle_set, deserialized); +} From c15e0d051f9e4bca6c17d61e0de45fa6a569431b Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 3 Apr 2026 04:32:18 +0000 Subject: [PATCH 6/8] fix: make OracleSet required fields non-optional and add validation OracleDocumentID and LastUpdateTime are required by the XRPL protocol but were typed as Option, allowing callers to construct invalid transactions that rippled would reject. Changed both to u32 to match the OracleDelete pattern and the protocol specification. Added price_data_series length validation (max 10 entries) in get_errors() to enforce the protocol limit at the model layer. --- src/models/transactions/oracle_set.rs | 108 +++++++++++++++++++++----- tests/transactions/oracle_set.rs | 10 +-- 2 files changed, 93 insertions(+), 25 deletions(-) diff --git a/src/models/transactions/oracle_set.rs b/src/models/transactions/oracle_set.rs index 8711c96f..83e6fb50 100644 --- a/src/models/transactions/oracle_set.rs +++ b/src/models/transactions/oracle_set.rs @@ -5,10 +5,13 @@ use serde_with::skip_serializing_none; use crate::models::amount::XRPAmount; use crate::models::transactions::{Memo, PriceData, Signer, Transaction, TransactionType}; -use crate::models::{FlagCollection, Model, NoFlags, XRPLModelResult}; +use crate::models::{FlagCollection, Model, NoFlags, XRPLModelException, XRPLModelResult}; use super::{CommonFields, CommonTransactionBuilder}; +/// Maximum number of PriceData entries allowed in a single OracleSet transaction. +const MAX_ORACLE_DATA_SERIES: u32 = 10; + /// An OracleSet transaction creates or updates an Oracle ledger entry. /// /// See OracleSet: @@ -25,7 +28,7 @@ pub struct OracleSet<'a> { pub common_fields: CommonFields<'a, NoFlags>, /// A unique identifier of the price oracle for the account. #[serde(rename = "OracleDocumentID")] - pub oracle_document_id: Option, + pub oracle_document_id: u32, /// An arbitrary value that identifies an oracle provider, such as /// Chainlink, Band, or DIA. This field is a string, up to 256 ASCII /// hex encoded characters (128 bytes). @@ -39,7 +42,7 @@ pub struct OracleSet<'a> { /// (8 bytes). pub asset_class: Option>, /// The time the data was last updated, represented in the ripple epoch. - pub last_update_time: Option, + pub last_update_time: u32, /// An array of up to 10 PriceData objects, each representing one /// price data entry. pub price_data_series: Option>, @@ -47,6 +50,15 @@ pub struct OracleSet<'a> { impl Model for OracleSet<'_> { fn get_errors(&self) -> XRPLModelResult<()> { + if let Some(ref series) = self.price_data_series { + if series.len() as u32 > MAX_ORACLE_DATA_SERIES { + return Err(XRPLModelException::ValueTooHigh { + field: "price_data_series".into(), + max: MAX_ORACLE_DATA_SERIES, + found: series.len() as u32, + }); + } + } Ok(()) } } @@ -86,11 +98,11 @@ impl<'a> OracleSet<'a> { signers: Option>, source_tag: Option, ticket_sequence: Option, - oracle_document_id: Option, + oracle_document_id: u32, provider: Option>, uri: Option>, asset_class: Option>, - last_update_time: Option, + last_update_time: u32, price_data_series: Option>, ) -> Self { Self { @@ -121,7 +133,7 @@ impl<'a> OracleSet<'a> { /// Set the oracle document ID pub fn with_oracle_document_id(mut self, id: u32) -> Self { - self.oracle_document_id = Some(id); + self.oracle_document_id = id; self } @@ -145,7 +157,7 @@ impl<'a> OracleSet<'a> { /// Set the last update time pub fn with_last_update_time(mut self, time: u32) -> Self { - self.last_update_time = Some(time); + self.last_update_time = time; self } @@ -173,11 +185,11 @@ mod tests { signing_pub_key: Some("".into()), ..Default::default() }, - oracle_document_id: Some(1), + oracle_document_id: 1, provider: Some("chainlink".into()), uri: Some("https://example.com/oracle1".into()), asset_class: Some("63757272656E6379".into()), - last_update_time: Some(743609014), + last_update_time: 743609014, price_data_series: Some(vec![PriceData { base_asset: Some("XRP".to_string()), quote_asset: Some("USD".to_string()), @@ -211,11 +223,11 @@ mod tests { .with_last_ledger_sequence(596447) .with_source_tag(42); - assert_eq!(oracle_set.oracle_document_id, Some(1)); + assert_eq!(oracle_set.oracle_document_id, 1); assert_eq!(oracle_set.provider.as_deref(), Some("chainlink")); assert_eq!(oracle_set.uri.as_deref(), Some("https://example.com")); assert_eq!(oracle_set.asset_class.as_deref(), Some("63757272656E6379")); - assert_eq!(oracle_set.last_update_time, Some(743609014)); + assert_eq!(oracle_set.last_update_time, 743609014); assert_eq!(oracle_set.common_fields.fee.as_ref().unwrap().0, "12"); assert_eq!(oracle_set.common_fields.sequence, Some(100)); assert_eq!(oracle_set.common_fields.last_ledger_sequence, Some(596447)); @@ -241,11 +253,11 @@ mod tests { oracle_set.common_fields.transaction_type, TransactionType::OracleSet ); - assert!(oracle_set.oracle_document_id.is_none()); + assert_eq!(oracle_set.oracle_document_id, 0); assert!(oracle_set.provider.is_none()); assert!(oracle_set.uri.is_none()); assert!(oracle_set.asset_class.is_none()); - assert!(oracle_set.last_update_time.is_none()); + assert_eq!(oracle_set.last_update_time, 0); assert!(oracle_set.price_data_series.is_none()); } @@ -297,11 +309,11 @@ mod tests { None, None, None, - Some(1), - None, + 1, None, None, None, + 743609014, None, ); @@ -313,7 +325,7 @@ mod tests { oracle_set.common_fields.transaction_type, TransactionType::OracleSet ); - assert_eq!(oracle_set.oracle_document_id, Some(1)); + assert_eq!(oracle_set.oracle_document_id, 1); } #[test] @@ -335,11 +347,11 @@ mod tests { None, None, None, - Some(1), + 1, Some("chainlink".into()), Some("https://example.com/oracle1".into()), Some("63757272656E6379".into()), - Some(743609014), + 743609014, Some(price_data), ); @@ -349,9 +361,9 @@ mod tests { ); assert_eq!(oracle_set.common_fields.fee, Some("12".into())); assert_eq!(oracle_set.common_fields.sequence, Some(391)); - assert_eq!(oracle_set.oracle_document_id, Some(1)); + assert_eq!(oracle_set.oracle_document_id, 1); assert_eq!(oracle_set.provider.as_deref(), Some("chainlink")); - assert_eq!(oracle_set.last_update_time, Some(743609014)); + assert_eq!(oracle_set.last_update_time, 743609014); assert_eq!(oracle_set.price_data_series.as_ref().unwrap().len(), 1); } @@ -432,4 +444,60 @@ mod tests { assert!(series[0].asset_price.is_none()); assert!(series[0].scale.is_none()); } + + #[test] + fn test_price_data_series_max_valid() { + let series: Vec = (0..10) + .map(|i| PriceData { + base_asset: Some(alloc::format!("ASSET{i}")), + quote_asset: Some("USD".to_string()), + asset_price: Some("100".to_string()), + scale: Some(1), + }) + .collect(); + + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(series); + + assert!(oracle_set.get_errors().is_ok()); + } + + #[test] + fn test_price_data_series_exceeds_max() { + let series: Vec = (0..11) + .map(|i| PriceData { + base_asset: Some(alloc::format!("ASSET{i}")), + quote_asset: Some("USD".to_string()), + asset_price: Some("100".to_string()), + scale: Some(1), + }) + .collect(); + + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(series); + + let err = oracle_set.get_errors().unwrap_err(); + assert_eq!( + err, + XRPLModelException::ValueTooHigh { + field: "price_data_series".into(), + max: 10, + found: 11, + } + ); + } } diff --git a/tests/transactions/oracle_set.rs b/tests/transactions/oracle_set.rs index 39e37634..3a5c3d1c 100644 --- a/tests/transactions/oracle_set.rs +++ b/tests/transactions/oracle_set.rs @@ -20,11 +20,11 @@ fn test_oracle_set_construction() { sequence: Some(391), ..Default::default() }, - oracle_document_id: Some(1), + oracle_document_id: 1, provider: Some("chainlink".into()), uri: Some("https://example.com/oracle".into()), asset_class: Some("63757272656E6379".into()), - last_update_time: Some(743609014), + last_update_time: 743609014, price_data_series: Some(vec![PriceData { base_asset: Some("XRP".into()), quote_asset: Some("USD".into()), @@ -37,7 +37,7 @@ fn test_oracle_set_construction() { oracle_set.common_fields.transaction_type, TransactionType::OracleSet ); - assert_eq!(oracle_set.oracle_document_id, Some(1)); + assert_eq!(oracle_set.oracle_document_id, 1); assert_eq!(oracle_set.price_data_series.as_ref().unwrap().len(), 1); } @@ -53,11 +53,11 @@ fn test_oracle_set_serde_roundtrip() { None, None, None, - Some(1), + 1, Some("provider".into()), None, None, - Some(743609014), + 743609014, None, ); From f57f0da0732ae22f4c1bee74da623313cde12c44 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Mon, 20 Apr 2026 05:48:02 +0000 Subject: [PATCH 7/8] fix(oracle-set): enforce PriceData bounds and oracle currency codes Address review findings on OracleSet / PriceData validation: - Reject an empty `price_data_series` with `ValueTooLow { min: 1 }` when the field is present. rippled requires at least one entry. - Implement `Model::get_errors` for `PriceData`, enforcing `0 <= scale <= 10` per XLS-47. - Validate `base_asset` and `quote_asset` when present: must be either a 3-character ISO-style code or a 40-character hex code, and cannot be the reserved symbol "XRP". `OracleSet::get_errors` now propagates each entry's validation error. - Update pre-existing tests that used "XRP" as an asset and add new cases: empty series rejection, scale boundary (10 ok, 11 rejected), invalid 4-char base asset rejection, explicit "XRP" rejection, and 40-char hex acceptance. --- src/models/transactions/mod.rs | 56 +++++++++ src/models/transactions/oracle_set.rs | 166 ++++++++++++++++++++++++-- tests/transactions/oracle_set.rs | 2 +- 3 files changed, 213 insertions(+), 11 deletions(-) diff --git a/src/models/transactions/mod.rs b/src/models/transactions/mod.rs index 0907263d..9dd6bf66 100644 --- a/src/models/transactions/mod.rs +++ b/src/models/transactions/mod.rs @@ -590,6 +590,62 @@ pub struct PriceData { } } +/// Maximum allowed value for the `scale` field of a `PriceData` entry. +/// +/// Per XLS-47, rippled enforces `0 <= scale <= 10`. +pub const MAX_PRICE_DATA_SCALE: u8 = 10; + +impl crate::models::Model for PriceData { + fn get_errors(&self) -> crate::models::XRPLModelResult<()> { + if let Some(scale) = self.scale { + if scale > MAX_PRICE_DATA_SCALE { + return Err(crate::models::XRPLModelException::ValueTooHigh { + field: "scale".into(), + max: MAX_PRICE_DATA_SCALE as u32, + found: scale as u32, + }); + } + } + if let Some(ref base_asset) = self.base_asset { + validate_oracle_currency("base_asset", base_asset)?; + } + if let Some(ref quote_asset) = self.quote_asset { + validate_oracle_currency("quote_asset", quote_asset)?; + } + Ok(()) + } +} + +/// Validate a currency code used in a `PriceData` entry. +/// +/// Accepts either a 3-character ISO-style code (uppercase letters and digits) +/// or a 40-character hex code. The reserved symbol `"XRP"` is rejected +/// because an XRP oracle price is not meaningful (XRP is the ledger's native +/// asset and is quoted directly, not via an IOU currency code). +fn validate_oracle_currency( + field: &'static str, + value: &str, +) -> crate::models::XRPLModelResult<()> { + if value == "XRP" { + return Err(crate::models::XRPLModelException::InvalidValue { + field: field.into(), + expected: + "a 3-character ISO currency code (excluding \"XRP\") or 40-character hex code" + .into(), + found: value.into(), + }); + } + if crate::utils::is_iso_code(value) || crate::utils::is_iso_hex(value) { + return Ok(()); + } + Err(crate::models::XRPLModelException::InvalidValue { + field: field.into(), + expected: "a 3-character ISO currency code (excluding \"XRP\") or 40-character hex code" + .into(), + found: value.into(), + }) +} + /// Standard functions for transactions. pub trait Transaction<'a, T> where diff --git a/src/models/transactions/oracle_set.rs b/src/models/transactions/oracle_set.rs index 83e6fb50..3798323d 100644 --- a/src/models/transactions/oracle_set.rs +++ b/src/models/transactions/oracle_set.rs @@ -51,6 +51,14 @@ pub struct OracleSet<'a> { impl Model for OracleSet<'_> { fn get_errors(&self) -> XRPLModelResult<()> { if let Some(ref series) = self.price_data_series { + // rippled requires at least one entry when the field is present. + if series.is_empty() { + return Err(XRPLModelException::ValueTooLow { + field: "price_data_series".into(), + min: 1, + found: 0, + }); + } if series.len() as u32 > MAX_ORACLE_DATA_SERIES { return Err(XRPLModelException::ValueTooHigh { field: "price_data_series".into(), @@ -58,6 +66,9 @@ impl Model for OracleSet<'_> { found: series.len() as u32, }); } + for entry in series { + entry.validate()?; + } } Ok(()) } @@ -191,7 +202,7 @@ mod tests { asset_class: Some("63757272656E6379".into()), last_update_time: 743609014, price_data_series: Some(vec![PriceData { - base_asset: Some("XRP".to_string()), + base_asset: Some("EUR".to_string()), quote_asset: Some("USD".to_string()), asset_price: Some("740".to_string()), scale: Some(1), @@ -201,6 +212,8 @@ mod tests { let serialized = serde_json::to_string(&oracle_set).unwrap(); let deserialized: OracleSet = serde_json::from_str(&serialized).unwrap(); assert_eq!(oracle_set, deserialized); + // `XRP` was rejected as a PriceData asset; ensure this model validates. + assert!(oracle_set.get_errors().is_ok()); } #[test] @@ -265,7 +278,7 @@ mod tests { fn test_with_price_data() { let price_data = vec![ PriceData { - base_asset: Some("XRP".to_string()), + base_asset: Some("EUR".to_string()), quote_asset: Some("USD".to_string()), asset_price: Some("740".to_string()), scale: Some(1), @@ -290,7 +303,7 @@ mod tests { let series = oracle_set.price_data_series.as_ref().unwrap(); assert_eq!(series.len(), 2); - assert_eq!(series[0].base_asset.as_deref(), Some("XRP")); + assert_eq!(series[0].base_asset.as_deref(), Some("EUR")); assert_eq!(series[0].quote_asset.as_deref(), Some("USD")); assert_eq!(series[0].asset_price.as_deref(), Some("740")); assert_eq!(series[0].scale, Some(1)); @@ -331,7 +344,7 @@ mod tests { #[test] fn test_new_constructor() { let price_data = vec![PriceData { - base_asset: Some("XRP".to_string()), + base_asset: Some("EUR".to_string()), quote_asset: Some("USD".to_string()), asset_price: Some("740".to_string()), scale: Some(1), @@ -405,7 +418,8 @@ mod tests { } #[test] - fn test_empty_price_data_series() { + fn test_empty_price_data_series_rejected() { + // When `price_data_series` is present, rippled requires at least 1 entry. let oracle_set = OracleSet { common_fields: CommonFields { account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), @@ -416,13 +430,23 @@ mod tests { } .with_price_data_series(vec![]); - assert_eq!(oracle_set.price_data_series.as_ref().unwrap().len(), 0); + let err = oracle_set.get_errors().unwrap_err(); + assert_eq!( + err, + XRPLModelException::ValueTooLow { + field: "price_data_series".into(), + min: 1, + found: 0, + } + ); } #[test] fn test_price_data_partial_fields() { + // All fields optional at the type level: a partial entry deserializes fine + // and, when both asset fields are absent, does not trigger currency validation. let price_data = PriceData { - base_asset: Some("XRP".to_string()), + base_asset: Some("EUR".to_string()), quote_asset: None, asset_price: None, scale: None, @@ -439,7 +463,7 @@ mod tests { .with_price_data_series(vec![price_data]); let series = oracle_set.price_data_series.as_ref().unwrap(); - assert_eq!(series[0].base_asset.as_deref(), Some("XRP")); + assert_eq!(series[0].base_asset.as_deref(), Some("EUR")); assert!(series[0].quote_asset.is_none()); assert!(series[0].asset_price.is_none()); assert!(series[0].scale.is_none()); @@ -447,9 +471,10 @@ mod tests { #[test] fn test_price_data_series_max_valid() { + // Use valid 3-char ISO-style codes for the per-entry currency validation. let series: Vec = (0..10) .map(|i| PriceData { - base_asset: Some(alloc::format!("ASSET{i}")), + base_asset: Some(alloc::format!("A{i:02}")), quote_asset: Some("USD".to_string()), asset_price: Some("100".to_string()), scale: Some(1), @@ -473,7 +498,7 @@ mod tests { fn test_price_data_series_exceeds_max() { let series: Vec = (0..11) .map(|i| PriceData { - base_asset: Some(alloc::format!("ASSET{i}")), + base_asset: Some(alloc::format!("A{i:02}")), quote_asset: Some("USD".to_string()), asset_price: Some("100".to_string()), scale: Some(1), @@ -500,4 +525,125 @@ mod tests { } ); } + + #[test] + fn test_scale_too_high_rejected() { + // Per XLS-47, `scale` must be in the inclusive range 0..=10. + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![PriceData { + base_asset: Some("EUR".to_string()), + quote_asset: Some("USD".to_string()), + asset_price: Some("100".to_string()), + scale: Some(11), + }]); + + let err = oracle_set.get_errors().unwrap_err(); + assert_eq!( + err, + XRPLModelException::ValueTooHigh { + field: "scale".into(), + max: 10, + found: 11, + } + ); + } + + #[test] + fn test_scale_at_max_ok() { + // Boundary: scale = 10 is explicitly permitted. + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![PriceData { + base_asset: Some("EUR".to_string()), + quote_asset: Some("USD".to_string()), + asset_price: Some("100".to_string()), + scale: Some(10), + }]); + + assert!(oracle_set.get_errors().is_ok()); + } + + #[test] + fn test_invalid_base_asset_rejected() { + // A 4-character code is neither a valid ISO code nor a 40-char hex. + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![PriceData { + base_asset: Some("EURO".to_string()), + quote_asset: Some("USD".to_string()), + asset_price: Some("100".to_string()), + scale: Some(1), + }]); + + let err = oracle_set.get_errors().unwrap_err(); + assert!(matches!( + err, + XRPLModelException::InvalidValue { ref field, .. } if field == "base_asset" + )); + } + + #[test] + fn test_xrp_as_asset_rejected() { + // XRP is the native asset and must not appear as a 3-character oracle currency code. + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![PriceData { + base_asset: Some("XRP".to_string()), + quote_asset: Some("USD".to_string()), + asset_price: Some("100".to_string()), + scale: Some(1), + }]); + + let err = oracle_set.get_errors().unwrap_err(); + assert!(matches!( + err, + XRPLModelException::InvalidValue { ref field, .. } if field == "base_asset" + )); + } + + #[test] + fn test_hex_currency_accepted() { + // 40-character hex currency codes are valid. + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![PriceData { + base_asset: Some("0000000000000000000000005553440000000000".to_string()), + quote_asset: Some("USD".to_string()), + asset_price: Some("100".to_string()), + scale: Some(0), + }]); + + assert!(oracle_set.get_errors().is_ok()); + } } diff --git a/tests/transactions/oracle_set.rs b/tests/transactions/oracle_set.rs index 3a5c3d1c..fef74e91 100644 --- a/tests/transactions/oracle_set.rs +++ b/tests/transactions/oracle_set.rs @@ -26,7 +26,7 @@ fn test_oracle_set_construction() { asset_class: Some("63757272656E6379".into()), last_update_time: 743609014, price_data_series: Some(vec![PriceData { - base_asset: Some("XRP".into()), + base_asset: Some("EUR".into()), quote_asset: Some("USD".into()), asset_price: Some("740".into()), scale: Some(1), From ec0d3565fd613d91c7497719071bd38e6effa019 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 17 Apr 2026 23:29:50 +0000 Subject: [PATCH 8/8] ci: pin rippled Docker image and harden health check wait The rippleci/rippled:develop image updated after 2026-04-01 and broke integration tests across all PRs (container exits before becoming healthy, causing Connection refused on localhost:5005). Pin to the last known-good digest and replace the simple until loop with a bounded retry that checks container liveness, prints status per attempt, and dumps container logs on failure. --- .github/workflows/integration_test.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 12e3df17..cd26abf8 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -11,7 +11,8 @@ on: name: Integration Test env: - RIPPLED_DOCKER_IMAGE: rippleci/rippled:develop + # Pin to known-good digest; rippleci/rippled:develop broke after 2026-04-01 + RIPPLED_DOCKER_IMAGE: rippleci/rippled:develop@sha256:328175bf14b7b83db9e5e6b50c7458bf828b02b2855453efc038233094aa8d85 jobs: integration_test: @@ -41,10 +42,22 @@ jobs: - name: Wait for rippled to be healthy run: | - until docker inspect --format='{{.State.Health.Status}}' rippled-service | grep -q healthy; do - echo "Waiting for rippled to be ready..." + for i in $(seq 1 30); do + if ! docker ps -q -f name=rippled-service | grep -q .; then + echo "Container exited unexpectedly" + docker logs rippled-service 2>&1 || true + exit 1 + fi + STATUS=$(docker inspect --format='{{.State.Health.Status}}' rippled-service 2>/dev/null || echo "unknown") + echo "Attempt $i/30: $STATUS" + if [ "$STATUS" = "healthy" ]; then + exit 0 + fi sleep 2 done + echo "Timed out waiting for rippled" + docker logs rippled-service 2>&1 || true + exit 1 - uses: dtolnay/rust-toolchain@stable