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 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")); + } +} diff --git a/src/models/transactions/mod.rs b/src/models/transactions/mod.rs index 167d56c8..9dd6bf66 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,76 @@ 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, +} +} + +/// 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_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); + } +} diff --git a/src/models/transactions/oracle_set.rs b/src/models/transactions/oracle_set.rs new file mode 100644 index 00000000..3798323d --- /dev/null +++ b/src/models/transactions/oracle_set.rs @@ -0,0 +1,649 @@ +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, 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: +/// `` +#[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: 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). + 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: u32, + /// 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<()> { + 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(), + max: MAX_ORACLE_DATA_SERIES, + found: series.len() as u32, + }); + } + for entry in series { + entry.validate()?; + } + } + 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: u32, + provider: Option>, + uri: Option>, + asset_class: Option>, + last_update_time: u32, + 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 = 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 = 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: 1, + provider: Some("chainlink".into()), + uri: Some("https://example.com/oracle1".into()), + asset_class: Some("63757272656E6379".into()), + last_update_time: 743609014, + price_data_series: Some(vec![PriceData { + base_asset: Some("EUR".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); + // `XRP` was rejected as a PriceData asset; ensure this model validates. + assert!(oracle_set.get_errors().is_ok()); + } + + #[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, 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, 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_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_eq!(oracle_set.last_update_time, 0); + assert!(oracle_set.price_data_series.is_none()); + } + + #[test] + fn test_with_price_data() { + let price_data = vec![ + PriceData { + base_asset: Some("EUR".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("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)); + 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, + 1, + None, + None, + None, + 743609014, + 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, 1); + } + + #[test] + fn test_new_constructor() { + let price_data = vec![PriceData { + base_asset: Some("EUR".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, + 1, + Some("chainlink".into()), + Some("https://example.com/oracle1".into()), + Some("63757272656E6379".into()), + 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, 1); + assert_eq!(oracle_set.provider.as_deref(), Some("chainlink")); + assert_eq!(oracle_set.last_update_time, 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_rejected() { + // When `price_data_series` is present, rippled requires at least 1 entry. + let oracle_set = OracleSet { + common_fields: CommonFields { + account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![]); + + 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("EUR".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("EUR")); + assert!(series[0].quote_asset.is_none()); + assert!(series[0].asset_price.is_none()); + assert!(series[0].scale.is_none()); + } + + #[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!("A{i:02}")), + 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!("A{i:02}")), + 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, + } + ); + } + + #[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/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..fef74e91 --- /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: 1, + provider: Some("chainlink".into()), + uri: Some("https://example.com/oracle".into()), + asset_class: Some("63757272656E6379".into()), + last_update_time: 743609014, + price_data_series: Some(vec![PriceData { + base_asset: Some("EUR".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, 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, + 1, + Some("provider".into()), + None, + None, + 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); +}