diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index b6c9ae3e..e6fa2ff0 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -76,7 +76,7 @@ jobs: --ignore-filename-regex '${{ env.COVERAGE_IGNORE_REGEX }}' - name: Upload coverage to Codecov - uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4 + uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5 with: token: ${{ secrets.CODECOV_TOKEN }} files: lcov.info diff --git a/.gitignore b/.gitignore index 23cd610a..8fb11e38 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ **/.DS_Store rustc-ice* +.local/* +dev/* +.playwright-mcp/* diff --git a/src/models/ledger/objects/mod.rs b/src/models/ledger/objects/mod.rs index fdae63db..f6a5b954 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..4f13ae91 --- /dev/null +++ b/src/models/ledger/objects/oracle.rs @@ -0,0 +1,258 @@ +use crate::models::ledger::objects::LedgerEntryType; +use crate::models::transactions::PriceData; +use crate::models::{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: Cow<'a, str>, + /// An array of up to 10 PriceData objects, representing the price information. + pub price_data_series: Vec, + /// 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: Cow<'a, str>, + /// 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, + /// A unique identifier of the price oracle for the account, if present in + /// the ledger entry returned by the server. + #[serde(rename = "OracleDocumentID")] + pub oracle_document_id: Option, +} + +impl Model for Oracle<'_> {} + +impl<'a> LedgerObject for Oracle<'a> { + fn get_ledger_entry_type(&self) -> LedgerEntryType { + self.common_fields.get_ledger_entry_type() + } +} + +#[cfg(test)] +mod test_serde { + use super::*; + use crate::models::transactions::PriceData; + use crate::models::FlagCollection; + use alloc::borrow::Cow; + use alloc::string::ToString; + use alloc::vec; + + #[test] + fn test_serialize() { + let oracle = Oracle { + common_fields: CommonFields { + flags: FlagCollection::default(), + ledger_entry_type: LedgerEntryType::Oracle, + index: Some(Cow::from("ForTest")), + ledger_index: None, + }, + owner: Cow::from("rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"), + provider: Cow::from("636861696E6C696E6B"), + asset_class: Cow::from("63757272656E6379"), + price_data_series: vec![PriceData { + base_asset: "XRP".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("2E4".to_string()), + scale: Some(1), + }], + last_update_time: 743609014, + uri: Some(Cow::from("68747470733A2F2F6578616D706C652E636F6D")), + owner_node: Cow::from("0000000000000000"), + previous_txn_id: Cow::from("ABC123DEF456"), + previous_txn_lgr_seq: 12345678, + oracle_document_id: Some(1), + }; + + let serialized = serde_json::to_string(&oracle).unwrap(); + let deserialized: Oracle = serde_json::from_str(&serialized).unwrap(); + assert_eq!(oracle, deserialized); + } + + #[test] + fn test_deserialize() { + let json = r#"{ + "LedgerEntryType": "Oracle", + "Flags": 0, + "Owner": "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW", + "Provider": "636861696E6C696E6B", + "AssetClass": "63757272656E6379", + "PriceDataSeries": [ + { + "PriceData": { + "BaseAsset": "XRP", + "QuoteAsset": "USD", + "AssetPrice": "2E4", + "Scale": 1 + } + } + ], + "LastUpdateTime": 743609014, + "URI": "68747470733A2F2F6578616D706C652E636F6D", + "OwnerNode": "0000000000000000", + "PreviousTxnID": "ABC123DEF456", + "PreviousTxnLgrSeq": 12345678, + "OracleDocumentID": 1 + }"#; + + let oracle: Oracle = serde_json::from_str(json).unwrap(); + assert_eq!(oracle.owner, "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"); + assert_eq!(oracle.provider, "636861696E6C696E6B"); + assert_eq!(oracle.asset_class, "63757272656E6379"); + assert_eq!(oracle.price_data_series.len(), 1); + assert_eq!(oracle.price_data_series[0].base_asset, "XRP"); + assert_eq!(oracle.price_data_series[0].quote_asset, "USD"); + assert_eq!(oracle.price_data_series[0].asset_price, Some("2E4".into())); + assert_eq!(oracle.price_data_series[0].scale, Some(1)); + assert_eq!(oracle.last_update_time, 743609014); + assert_eq!( + oracle.uri, + Some("68747470733A2F2F6578616D706C652E636F6D".into()) + ); + assert_eq!(oracle.owner_node, "0000000000000000"); + assert_eq!(oracle.previous_txn_id, "ABC123DEF456"); + assert_eq!(oracle.previous_txn_lgr_seq, 12345678); + assert_eq!(oracle.oracle_document_id, Some(1)); + } + + #[test] + fn test_new_minimal() { + let oracle = Oracle { + common_fields: CommonFields { + flags: FlagCollection::default(), + ledger_entry_type: LedgerEntryType::Oracle, + index: None, + ledger_index: None, + }, + owner: Cow::from("rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"), + provider: Cow::from("70726F766964657231"), + asset_class: Cow::from("63757272656E6379"), + price_data_series: vec![PriceData { + base_asset: "XRP".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("2E4".to_string()), + scale: Some(1), + }], + last_update_time: 743609014, + uri: None, + owner_node: Cow::from("0000000000000000"), + previous_txn_id: Cow::from("ABC123"), + previous_txn_lgr_seq: 100, + oracle_document_id: None, + }; + + assert_eq!(oracle.owner, "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"); + assert_eq!(oracle.provider, "70726F766964657231"); + assert_eq!(oracle.asset_class, "63757272656E6379"); + assert_eq!(oracle.price_data_series.len(), 1); + assert_eq!(oracle.last_update_time, 743609014); + assert!(oracle.uri.is_none()); + assert_eq!(oracle.owner_node, "0000000000000000"); + assert_eq!(oracle.previous_txn_id, "ABC123"); + assert_eq!(oracle.previous_txn_lgr_seq, 100); + } + + #[test] + fn test_ledger_entry_type() { + let oracle = Oracle { + common_fields: CommonFields { + flags: FlagCollection::default(), + ledger_entry_type: LedgerEntryType::Oracle, + index: None, + ledger_index: None, + }, + owner: Cow::from("rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"), + provider: Cow::from("70726F766964657231"), + asset_class: Cow::from("63757272656E6379"), + price_data_series: vec![PriceData { + base_asset: "XRP".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("2E4".to_string()), + scale: Some(1), + }], + last_update_time: 0, + uri: None, + owner_node: Cow::from("0000000000000000"), + previous_txn_id: Cow::from("ABC123"), + previous_txn_lgr_seq: 0, + oracle_document_id: None, + }; + + assert_eq!(oracle.get_ledger_entry_type(), LedgerEntryType::Oracle); + } + + #[test] + fn test_with_multiple_price_data() { + let oracle = Oracle { + common_fields: CommonFields { + flags: FlagCollection::default(), + ledger_entry_type: LedgerEntryType::Oracle, + index: Some(Cow::from("TestIndex")), + ledger_index: None, + }, + owner: Cow::from("rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"), + provider: Cow::from("636861696E6C696E6B"), + asset_class: Cow::from("63757272656E6379"), + price_data_series: vec![ + PriceData { + base_asset: "XRP".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("2E4".to_string()), + scale: Some(1), + }, + PriceData { + base_asset: "BTC".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("27AC40".to_string()), + scale: Some(2), + }, + PriceData { + base_asset: "ETH".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("27100".to_string()), + scale: Some(2), + }, + ], + last_update_time: 743609014, + uri: Some(Cow::from("68747470733A2F2F6578616D706C652E636F6D")), + owner_node: Cow::from("0000000000000000"), + previous_txn_id: Cow::from("DEF789"), + previous_txn_lgr_seq: 99999, + oracle_document_id: None, + }; + + let series = oracle.price_data_series; + assert_eq!(series.len(), 3); + assert_eq!(series[0].base_asset, "XRP"); + assert_eq!(series[1].base_asset, "BTC"); + assert_eq!(series[2].base_asset, "ETH"); + } +} diff --git a/src/models/requests/account_objects.rs b/src/models/requests/account_objects.rs index a3fd20c5..f3f67564 100644 --- a/src/models/requests/account_objects.rs +++ b/src/models/requests/account_objects.rs @@ -17,6 +17,7 @@ pub enum AccountObjectType { DepositPreauth, Escrow, Offer, + Oracle, PaymentChannel, SignerList, State, @@ -43,7 +44,7 @@ pub struct AccountObjects<'a> { pub ledger_lookup: Option>, /// If included, filter results to include only this type /// of ledger object. The valid types are: check, deposit_preauth, - /// escrow, offer, payment_channel, signer_list, ticket, + /// escrow, offer, oracle, payment_channel, signer_list, ticket, /// and state (trust line). pub r#type: Option, /// If true, the response only includes objects that would block diff --git a/src/models/transactions/mod.rs b/src/models/transactions/mod.rs index 167d56c8..9d6762ba 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,99 @@ 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: String, + pub quote_asset: String, + /// The token pair's price. When omitted on an OracleSet update, rippled + /// deletes the existing price data entry for this base/quote pair. + pub asset_price: Option, + pub scale: Option, +} +} + +/// Maximum allowed value for the `scale` field of a `PriceData` entry. +/// +/// Per rippled (`kMaxPriceScale` in `Protocol.h`), `scale` must be in the +/// inclusive range `0..=20`. +pub const MAX_PRICE_DATA_SCALE: u8 = 20; + +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 self.asset_price.is_some() != self.scale.is_some() { + return Err(crate::models::XRPLModelException::InvalidValue { + field: "price_data".into(), + expected: "AssetPrice and Scale both present or both omitted".into(), + found: alloc::format!( + "asset_price_present={}, scale_present={}", + self.asset_price.is_some(), + self.scale.is_some() + ), + }); + } + validate_oracle_currency("base_asset", &self.base_asset)?; + validate_oracle_currency("quote_asset", &self.quote_asset)?; + validate_oracle_asset_price(&self.asset_price)?; + Ok(()) + } +} + +/// Maximum allowed value for `AssetPrice`. +/// +/// `AssetPrice` is a `UInt64` field; the full unsigned 64-bit range +/// (`0x0000000000000000..=0xFFFFFFFFFFFFFFFF`) is valid. rippled does not +/// impose an upper-bound smaller than `u64::MAX` (`kMaxPriceScale` only +/// governs Scale, not AssetPrice). +pub const MAX_ORACLE_ASSET_PRICE: u64 = u64::MAX; + +/// Validate a currency code used in a `PriceData` entry. +/// +/// Accepts either a 3-character ISO-style code (uppercase letters and digits, +/// including `"XRP"`) or a 40-character hex code. +fn validate_oracle_currency( + field: &'static str, + value: &str, +) -> crate::models::XRPLModelResult<()> { + 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 (including \"XRP\") or 40-character hex code" + .into(), + found: value.into(), + }) +} + +fn validate_oracle_asset_price(value: &Option) -> crate::models::XRPLModelResult<()> { + let Some(value) = value else { + return Ok(()); + }; + match u64::from_str_radix(value, 16) { + Ok(_) => Ok(()), + Err(_) => Err(crate::models::XRPLModelException::InvalidValue { + field: "asset_price".into(), + expected: "a valid UInt64 hexadecimal string (0x0000000000000000..=0xFFFFFFFFFFFFFFFF)" + .into(), + found: value.clone(), + }), + } +} + /// 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..c490c917 --- /dev/null +++ b/src/models/transactions/oracle_delete.rs @@ -0,0 +1,279 @@ +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 { + account, + transaction_type: TransactionType::OracleDelete, + account_txn_id, + fee, + flags: FlagCollection::default(), + last_ledger_sequence, + memos, + network_id: None, + sequence, + signers, + signing_pub_key: None, // filled by the signing layer + source_tag, + ticket_sequence, + txn_signature: None, // filled by the signing layer + }, + oracle_document_id, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Canonical test account used across all OracleDelete unit tests. + const TEST_ACCOUNT: &str = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"; + const TEST_FEE: &str = "12"; + const TEST_SEQUENCE: u32 = 391; + const TEST_LAST_LEDGER: u32 = 596447; + const TEST_DOC_ID: u32 = 1; + + #[test] + fn test_serde() { + let oracle_delete = OracleDelete { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleDelete, + fee: Some(TEST_FEE.into()), + sequence: Some(TEST_SEQUENCE), + signing_pub_key: Some("".into()), + ..Default::default() + }, + oracle_document_id: TEST_DOC_ID, + }; + + 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) + .expect("OracleDelete should serialize to JSON without error"); + let serialized_value = serde_json::to_value(&serialized_string) + .expect("serialized OracleDelete string should be valid JSON"); + let default_json_value = + serde_json::to_value(default_json_str).expect("expected JSON string is valid JSON"); + assert_eq!(serialized_value, default_json_value); + + let deserialized: OracleDelete = serde_json::from_str(default_json_str) + .expect("OracleDelete should deserialize from expected JSON"); + assert_eq!(oracle_delete, deserialized); + } + + #[test] + fn test_builder_pattern() { + let oracle_delete = OracleDelete { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleDelete, + ..Default::default() + }, + oracle_document_id: TEST_DOC_ID, + } + .with_fee(TEST_FEE.into()) + .with_sequence(TEST_SEQUENCE) + .with_last_ledger_sequence(TEST_LAST_LEDGER) + .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, TEST_DOC_ID); + assert_eq!( + oracle_delete.common_fields.fee.as_ref().unwrap().0, + TEST_FEE + ); + assert_eq!(oracle_delete.common_fields.sequence, Some(TEST_SEQUENCE)); + assert_eq!( + oracle_delete.common_fields.last_ledger_sequence, + Some(TEST_LAST_LEDGER) + ); + 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: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleDelete, + ..Default::default() + }, + oracle_document_id: 5, + }; + + assert_eq!(oracle_delete.common_fields.account, TEST_ACCOUNT); + 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 { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleDelete, + fee: Some(TEST_FEE.into()), + last_ledger_sequence: Some(TEST_LAST_LEDGER), + sequence: Some(TEST_SEQUENCE), + ..Default::default() + }, + oracle_document_id: TEST_DOC_ID, + }; + + assert_eq!( + oracle_delete.common_fields.transaction_type, + TransactionType::OracleDelete + ); + assert_eq!(oracle_delete.common_fields.fee, Some(TEST_FEE.into())); + assert_eq!(oracle_delete.common_fields.sequence, Some(TEST_SEQUENCE)); + assert_eq!( + oracle_delete.common_fields.last_ledger_sequence, + Some(TEST_LAST_LEDGER) + ); + assert_eq!(oracle_delete.oracle_document_id, TEST_DOC_ID); + } + + #[test] + fn test_transaction_type() { + let oracle_delete = OracleDelete { + common_fields: CommonFields { + account: TEST_ACCOUNT.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: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleDelete, + ..Default::default() + }, + oracle_document_id: 3, + } + .with_ticket_sequence(54321) + .with_fee(TEST_FEE.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 { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleDelete, + ..Default::default() + }, + oracle_document_id: 0, + }; + + assert_eq!(oracle_delete.oracle_document_id, 0); + } + + #[test] + fn test_max_document_id() { + let oracle_delete = OracleDelete { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleDelete, + ..Default::default() + }, + oracle_document_id: 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..bf542306 --- /dev/null +++ b/src/models/transactions/oracle_set.rs @@ -0,0 +1,993 @@ +use alloc::borrow::Cow; +use alloc::collections::BTreeSet; +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. +/// Matches rippled `kMaxOracleDataSeries` in `Protocol.h`. +const MAX_ORACLE_DATA_SERIES: u32 = 10; +/// Maximum decoded byte length for the `Provider` Blob field. +/// The hex string on the wire may therefore be up to 512 characters long. +/// Matches rippled `kMaxOracleProvider = 256` in `Protocol.h`. +const MAX_ORACLE_PROVIDER_DECODED_BYTES: usize = 256; +/// Maximum decoded byte length for the `URI` Blob field. +/// Matches rippled `kMaxOracleUri = 256` in `Protocol.h`. +const MAX_ORACLE_URI_DECODED_BYTES: usize = 256; +/// Maximum decoded byte length for the `AssetClass` Blob field. +/// Matches rippled `kMaxOracleSymbolClass = 16` in `Protocol.h`. +const MAX_ORACLE_ASSET_CLASS_DECODED_BYTES: usize = 16; + +/// 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 1 to 10 PriceData objects, each representing one + /// price data entry. + pub price_data_series: Vec, +} + +impl Model for OracleSet<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + validate_optional_blob( + "provider", + self.provider.as_deref(), + MAX_ORACLE_PROVIDER_DECODED_BYTES, + )?; + validate_optional_blob("uri", self.uri.as_deref(), MAX_ORACLE_URI_DECODED_BYTES)?; + validate_optional_blob( + "asset_class", + self.asset_class.as_deref(), + MAX_ORACLE_ASSET_CLASS_DECODED_BYTES, + )?; + + let series = &self.price_data_series; + 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, + }); + } + + let mut pairs = BTreeSet::new(); + for entry in series { + entry.validate()?; + if entry.base_asset == entry.quote_asset { + return Err(XRPLModelException::ValueEqualsValue { + field1: "base_asset".into(), + field2: "quote_asset".into(), + }); + } + let pair = (entry.base_asset.clone(), entry.quote_asset.clone()); + if !pairs.insert(pair) { + return Err(XRPLModelException::InvalidValue { + field: "price_data_series".into(), + expected: "unique BaseAsset/QuoteAsset pairs".into(), + found: alloc::format!("{}/{}", entry.base_asset, entry.quote_asset), + }); + } + } + Ok(()) + } +} + +fn validate_optional_blob( + field: &'static str, + value: Option<&str>, + max_bytes: usize, +) -> XRPLModelResult<()> { + let Some(value) = value else { + return Ok(()); + }; + let bytes = hex::decode(value).map_err(|e| { + use hex::FromHexError; + let reason = match e { + FromHexError::OddLength => "hex string has odd length (incomplete byte)", + FromHexError::InvalidHexCharacter { .. } => "non-hexadecimal character in string", + FromHexError::InvalidStringLength => "invalid hex string length", + }; + XRPLModelException::InvalidValue { + field: field.into(), + expected: alloc::format!("a valid hex-encoded Blob string ({reason})"), + found: value.into(), + } + })?; + // rippled `isInvalidLength` rejects empty blobs (length == 0) with + // `temMALFORMED`, matching the binary-codec requirement that Blob fields + // be non-empty when present. + if bytes.is_empty() { + return Err(XRPLModelException::InvalidValue { + field: field.into(), + expected: "a non-empty hex-encoded Blob string (empty strings are rejected)".into(), + found: value.into(), + }); + } + if bytes.len() > max_bytes { + return Err(XRPLModelException::ValueTooLong { + field: field.into(), + max: max_bytes, + found: bytes.len(), + }); + } + 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: Vec, + ) -> Self { + Self { + common_fields: CommonFields { + account, + transaction_type: TransactionType::OracleSet, + account_txn_id, + fee, + flags: FlagCollection::default(), + last_ledger_sequence, + memos, + network_id: None, + sequence, + signers, + signing_pub_key: None, // filled by the signing layer + source_tag, + ticket_sequence, + txn_signature: None, // filled by the signing layer + }, + 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 = series; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::string::ToString; + use alloc::vec; + + /// Canonical test account used across all OracleSet unit tests. + const TEST_ACCOUNT: &str = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"; + const TEST_FEE: &str = "12"; + const TEST_SEQUENCE: u32 = 391; + const TEST_LAST_LEDGER: u32 = 596447; + const TEST_DOC_ID: u32 = 1; + const TEST_LAST_UPDATE_TIME: u32 = 743609014; + /// "chainlink" hex-encoded (Provider is a Blob field). + const TEST_PROVIDER: &str = "636861696E6C696E6B"; + /// "currency" hex-encoded (AssetClass is a Blob field). + const TEST_ASSET_CLASS: &str = "63757272656E6379"; + + #[test] + fn test_serde() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.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("636861696E6C696E6B".into()), + uri: Some("68747470733A2F2F6578616D706C652E636F6D2F6F7261636C6531".into()), + asset_class: Some("63757272656E6379".into()), + last_update_time: 743609014, + price_data_series: vec![PriceData { + base_asset: "EUR".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("740".to_string()), + scale: Some(1), + }], + }; + + let serialized = serde_json::to_string(&oracle_set) + .expect("OracleSet should serialize to JSON without error"); + let deserialized: OracleSet = serde_json::from_str(&serialized) + .expect("OracleSet should deserialize from its own JSON output"); + 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: TEST_ACCOUNT.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: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + }; + + assert_eq!(oracle_set.common_fields.account, TEST_ACCOUNT); + 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_empty()); + } + + #[test] + fn test_with_price_data() { + let price_data = vec![ + PriceData { + base_asset: "EUR".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("740".to_string()), + scale: Some(1), + }, + PriceData { + base_asset: "BTC".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("2600000".to_string()), + scale: Some(2), + }, + ]; + + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(price_data.clone()); + + let series = oracle_set.price_data_series; + assert_eq!(series.len(), 2); + assert_eq!(series[0].base_asset, "EUR"); + assert_eq!(series[0].quote_asset, "USD"); + assert_eq!(series[0].asset_price.as_deref(), Some("740")); + assert_eq!(series[0].scale, Some(1)); + assert_eq!(series[1].base_asset, "BTC"); + } + + #[test] + fn test_minimal() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + oracle_document_id: TEST_DOC_ID, + last_update_time: TEST_LAST_UPDATE_TIME, + price_data_series: vec![], + ..Default::default() + }; + + assert_eq!(oracle_set.common_fields.account, TEST_ACCOUNT); + assert_eq!( + oracle_set.common_fields.transaction_type, + TransactionType::OracleSet + ); + assert_eq!(oracle_set.oracle_document_id, TEST_DOC_ID); + } + + #[test] + fn test_new_constructor() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + fee: Some(TEST_FEE.into()), + last_ledger_sequence: Some(TEST_LAST_LEDGER), + sequence: Some(TEST_SEQUENCE), + ..Default::default() + }, + oracle_document_id: TEST_DOC_ID, + // Non-hex plain string used here intentionally to test that the + // constructor stores values verbatim (validation is in get_errors). + provider: Some("chainlink".into()), + uri: Some("68747470733A2F2F6578616D706C652E636F6D2F6F7261636C6531".into()), + asset_class: Some(TEST_ASSET_CLASS.into()), + last_update_time: TEST_LAST_UPDATE_TIME, + price_data_series: vec![PriceData { + base_asset: "EUR".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("2E4".to_string()), + scale: Some(1), + }], + }; + + assert_eq!( + oracle_set.common_fields.transaction_type, + TransactionType::OracleSet + ); + assert_eq!(oracle_set.common_fields.fee, Some(TEST_FEE.into())); + assert_eq!(oracle_set.common_fields.sequence, Some(TEST_SEQUENCE)); + assert_eq!(oracle_set.oracle_document_id, TEST_DOC_ID); + assert_eq!(oracle_set.provider.as_deref(), Some("chainlink")); + assert_eq!(oracle_set.last_update_time, TEST_LAST_UPDATE_TIME); + assert_eq!(oracle_set.price_data_series.len(), 1); + } + + #[test] + fn test_transaction_type() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.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: TEST_ACCOUNT.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: TEST_ACCOUNT.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_optional_update_fields() { + // BaseAsset and QuoteAsset are required protocol fields. AssetPrice and + // Scale remain optional; omitting AssetPrice on update deletes the pair. + let price_data = PriceData { + base_asset: "EUR".to_string(), + quote_asset: "USD".to_string(), + asset_price: None, + scale: None, + }; + + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![price_data]); + + let series = oracle_set.price_data_series; + assert_eq!(series[0].base_asset, "EUR"); + assert_eq!(series[0].quote_asset, "USD"); + 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: alloc::format!("A{i:02}"), + quote_asset: "USD".to_string(), + asset_price: Some("100".to_string()), + scale: Some(1), + }) + .collect(); + + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.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: alloc::format!("A{i:02}"), + quote_asset: "USD".to_string(), + asset_price: Some("100".to_string()), + scale: Some(1), + }) + .collect(); + + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.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 rippled `kMaxPriceScale = 20` in Protocol.h; scale 21 is rejected. + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![PriceData { + base_asset: "EUR".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("100".to_string()), + scale: Some(21), + }]); + + let err = oracle_set.get_errors().unwrap_err(); + assert_eq!( + err, + XRPLModelException::ValueTooHigh { + field: "scale".into(), + max: 20, + found: 21, + } + ); + } + + #[test] + fn test_scale_at_max_ok() { + // Boundary: scale = 20 is explicitly permitted (kMaxPriceScale = 20). + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![PriceData { + base_asset: "EUR".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("100".to_string()), + scale: Some(20), + }]); + + assert!(oracle_set.get_errors().is_ok()); + } + + #[test] + fn test_scale_mid_range_ok() { + // Values 11-20 must also pass; they were incorrectly rejected before. + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![PriceData { + base_asset: "EUR".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("100".to_string()), + scale: Some(15), + }]); + + assert!(oracle_set.get_errors().is_ok()); + } + + #[test] + fn test_asset_price_and_scale_must_be_paired() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![PriceData { + base_asset: "XRP".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("100".to_string()), + scale: None, + }]); + + assert!(matches!( + oracle_set.get_errors().unwrap_err(), + XRPLModelException::InvalidValue { ref field, .. } if field == "price_data" + )); + } + + #[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: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![PriceData { + base_asset: "EURO".to_string(), + quote_asset: "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_accepted() { + // XRP is valid as an oracle currency code. + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![PriceData { + base_asset: "XRP".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("100".to_string()), + scale: Some(1), + }]); + + assert!(oracle_set.get_errors().is_ok()); + } + + #[test] + fn test_hex_currency_accepted() { + // 40-character hex currency codes are valid. + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![PriceData { + base_asset: "0000000000000000000000005553440000000000".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("100".to_string()), + scale: Some(0), + }]); + + assert!(oracle_set.get_errors().is_ok()); + } + + #[test] + fn test_oracle_metadata_lengths_rejected() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + provider: Some("AA".repeat(MAX_ORACLE_PROVIDER_DECODED_BYTES + 1).into()), + price_data_series: vec![PriceData { + base_asset: "XRP".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("100".to_string()), + scale: Some(1), + }], + ..Default::default() + }; + + assert!(matches!( + oracle_set.get_errors().unwrap_err(), + XRPLModelException::ValueTooLong { ref field, max, .. } + if field == "provider" && max == MAX_ORACLE_PROVIDER_DECODED_BYTES + )); + } + + #[test] + fn test_oracle_metadata_must_be_hex() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + provider: Some("chainlink".into()), + price_data_series: vec![PriceData { + base_asset: "XRP".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("100".to_string()), + scale: Some(1), + }], + ..Default::default() + }; + + assert!(matches!( + oracle_set.get_errors().unwrap_err(), + XRPLModelException::InvalidValue { ref field, .. } if field == "provider" + )); + } + + #[test] + fn test_asset_price_full_u64_range_accepted() { + // AssetPrice is a plain UInt64 — the full unsigned range is valid, + // including 0x8000000000000000..=0xFFFFFFFFFFFFFFFF. + // xrpl.js integration test uses "ffffffffffffffff" successfully. + for price in ["8000000000000000", "FFFFFFFFFFFFFFFF", "1", "0"] { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![PriceData { + base_asset: "XRP".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some(price.to_string()), + scale: Some(1), + }]); + + assert!( + oracle_set.get_errors().is_ok(), + "AssetPrice {price} should be valid" + ); + } + } + + #[test] + fn test_asset_price_non_hex_rejected() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![PriceData { + base_asset: "XRP".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("ZZZZZZZZZZZZZZZZ".to_string()), + scale: Some(1), + }]); + + assert!(matches!( + oracle_set.get_errors().unwrap_err(), + XRPLModelException::InvalidValue { ref field, .. } if field == "asset_price" + )); + } + + #[test] + fn test_empty_blob_fields_rejected() { + // rippled rejects zero-length Provider/URI/AssetClass with temMALFORMED. + for (field_name, oracle) in [ + ( + "provider", + OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + provider: Some("".into()), + price_data_series: vec![PriceData { + base_asset: "XRP".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("100".to_string()), + scale: Some(1), + }], + ..Default::default() + }, + ), + ( + "uri", + OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + uri: Some("".into()), + price_data_series: vec![PriceData { + base_asset: "XRP".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("100".to_string()), + scale: Some(1), + }], + ..Default::default() + }, + ), + ( + "asset_class", + OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + asset_class: Some("".into()), + price_data_series: vec![PriceData { + base_asset: "XRP".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("100".to_string()), + scale: Some(1), + }], + ..Default::default() + }, + ), + ] { + assert!( + matches!( + oracle.get_errors().unwrap_err(), + XRPLModelException::InvalidValue { ref field, .. } if field == field_name + ), + "empty {field_name} should be rejected" + ); + } + } + + #[test] + fn test_duplicate_price_data_pair_rejected() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![ + PriceData { + base_asset: "XRP".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("100".to_string()), + scale: Some(1), + }, + PriceData { + base_asset: "XRP".to_string(), + quote_asset: "USD".to_string(), + asset_price: Some("101".to_string()), + scale: Some(1), + }, + ]); + + assert!(matches!( + oracle_set.get_errors().unwrap_err(), + XRPLModelException::InvalidValue { ref field, .. } if field == "price_data_series" + )); + } + + #[test] + fn test_same_base_quote_rejected() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + ..Default::default() + } + .with_price_data_series(vec![PriceData { + base_asset: "XRP".to_string(), + quote_asset: "XRP".to_string(), + asset_price: Some("100".to_string()), + scale: Some(1), + }]); + + assert!(matches!( + oracle_set.get_errors().unwrap_err(), + XRPLModelException::ValueEqualsValue { ref field1, ref field2 } + if field1 == "base_asset" && field2 == "quote_asset" + )); + } +} diff --git a/tests/common/constants.rs b/tests/common/constants.rs index d42e153a..e3bb94a7 100644 --- a/tests/common/constants.rs +++ b/tests/common/constants.rs @@ -11,3 +11,24 @@ pub const GENESIS_ACCOUNT: &str = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"; /// HTTP JSON-RPC endpoint for local Docker standalone rippled. pub const STANDALONE_URL: &str = "http://localhost:5005"; + +// --------------------------------------------------------------------------- +// Oracle / XLS-47 test fixtures +// --------------------------------------------------------------------------- + +/// Reusable test account (funded via faucet in integration tests). +pub const TEST_ACCOUNT: &str = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"; + +/// "chainlink" ASCII hex-encoded. +/// Provider and similar Blob fields must be hex; plain ASCII is rejected by +/// the binary codec with `TryFromStrError`. +pub const ORACLE_PROVIDER: &str = "636861696E6C696E6B"; + +/// "currency" ASCII hex-encoded (AssetClass Blob field). +pub const ORACLE_ASSET_CLASS: &str = "63757272656E6379"; + +/// Short opaque URI hex-encoded ("did_example"). +pub const ORACLE_URI: &str = "6469645F6578616D706C65"; + +/// "https://example.com" ASCII hex-encoded (used in construction-only tests). +pub const ORACLE_URI_HTTPS: &str = "68747470733A2F2F6578616D706C652E636F6D"; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 1e0cfb6c..682e2a77 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -36,9 +36,12 @@ pub async fn open_websocket( use anyhow::anyhow; let port = uri.port().unwrap_or(80); - let url = format!("{}:{}", uri.host_str().unwrap(), port); + let host = uri.host_str().expect("open_websocket: URI has no host"); + let url = format!("{host}:{port}"); - let tcp = TcpStream::connect(&url).await.unwrap(); + let tcp = TcpStream::connect(&url) + .await + .expect("open_websocket: TcpStream::connect failed"); let stream = FromTokio::new(tcp); let rng = OsRng; match AsyncWebSocketClient::open(stream, uri, rng, None, None).await { @@ -121,13 +124,19 @@ pub async fn generate_funded_wallet() -> Wallet { } /// Advance the ledger by one close. +/// +/// Panics if the HTTP round-trip fails so test failures are surfaced +/// immediately rather than silently proceeding with a stale ledger. #[cfg(feature = "std")] pub async fn ledger_accept() { - let _ = reqwest::Client::new() + reqwest::Client::new() .post(constants::STANDALONE_URL) .json(&serde_json::json!({"method": "ledger_accept", "params": [{}]})) .send() - .await; + .await + .expect("ledger_accept: HTTP request failed") + .error_for_status() + .expect("ledger_accept: server returned error status"); } /// Return the `close_time` of the most-recent validated ledger in Ripple epoch seconds. @@ -275,3 +284,38 @@ where ); ledger_accept().await; } + +/// Parameters for [`submit_tx`] — use struct literal syntax so each argument +/// is self-documenting at call sites. +#[cfg(feature = "std")] +pub struct SubmitOptions<'w> { + pub wallet: &'w Wallet, + /// Auto-fill sequence, fee, and other transaction fields before signing. + pub autofill: bool, + /// Validate that the fee satisfies the network's minimum requirement. + pub check_fee: bool, +} + +/// Submit a transaction without asserting success. Returns the raw +/// `engine_result` string so callers can assert specific `tec`/`tem` codes. +/// +/// Use [`test_transaction`] instead when you expect `tesSUCCESS`. +#[cfg(feature = "std")] +pub async fn submit_tx<'a, T, F>(tx: &mut T, opts: SubmitOptions<'_>) -> String +where + T: xrpl::models::transactions::Transaction<'a, F> + + xrpl::models::Model + + serde::Serialize + + serde::de::DeserializeOwned + + Clone + + core::fmt::Debug, + F: strum::IntoEnumIterator + serde::Serialize + core::fmt::Debug + PartialEq + Clone + 'a, +{ + use xrpl::asynch::transaction::sign_and_submit; + let client = get_client().await; + sign_and_submit(tx, client, opts.wallet, opts.autofill, opts.check_fee) + .await + .expect("submit_tx: sign_and_submit failed") + .engine_result + .to_string() +} diff --git a/tests/transactions/mod.rs b/tests/transactions/mod.rs index 487494ae..f954a701 100644 --- a/tests/transactions/mod.rs +++ b/tests/transactions/mod.rs @@ -21,6 +21,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..d5ed5b38 --- /dev/null +++ b/tests/transactions/oracle_delete.rs @@ -0,0 +1,149 @@ +// 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 crate::common::{ + constants::{ORACLE_ASSET_CLASS, ORACLE_PROVIDER, ORACLE_URI, TEST_ACCOUNT}, + generate_funded_wallet, get_ledger_close_time, submit_tx, test_transaction, + with_blockchain_lock, SubmitOptions, +}; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::requests::account_objects::{AccountObjectType, AccountObjects}; +use xrpl::models::requests::LedgerIndex; +use xrpl::models::results::account_objects::AccountObjects as AccountObjectsResult; +use xrpl::models::transactions::oracle_delete::OracleDelete; +use xrpl::models::transactions::oracle_set::OracleSet; +use xrpl::models::transactions::{CommonFields, PriceData, TransactionType}; + +#[test] +fn test_oracle_delete_construction() { + let oracle_delete = OracleDelete { + common_fields: CommonFields { + account: TEST_ACCOUNT.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); +} + +#[tokio::test] +async fn test_oracle_delete_submit() { + with_blockchain_lock(|| async { + let wallet = generate_funded_wallet().await; + // OracleSet LastUpdateTime is POSIX/Unix time. The ledger response uses + // Ripple epoch seconds, so convert before submitting. + let last_update_time = u32::try_from(get_ledger_close_time().await + 946_684_800) + .expect("LastUpdateTime overflows u32: ledger close time is too far in the future"); + let oracle_document_id = 2; + + let mut oracle_set = OracleSet { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + oracle_document_id: 2, + // Provider is a Blob, so it must be hex-encoded ("chainlink"). + provider: Some(ORACLE_PROVIDER.into()), + uri: Some(ORACLE_URI.into()), + asset_class: Some(ORACLE_ASSET_CLASS.into()), + last_update_time, + price_data_series: vec![PriceData { + base_asset: "XRP".into(), + quote_asset: "USD".into(), + // AssetPrice is a UInt64 hex string in XRPL binary JSON: 0x2E4 == 740. + asset_price: Some("2E4".into()), + scale: Some(1), + }], + }; + test_transaction(&mut oracle_set, &wallet).await; + + let mut oracle_delete = OracleDelete { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::OracleDelete, + ..Default::default() + }, + oracle_document_id, + }; + test_transaction(&mut oracle_delete, &wallet).await; + + let client = crate::common::get_client().await; + let response = client + .request( + AccountObjects { + account: wallet.classic_address.clone().into(), + ledger_lookup: Some(xrpl::models::requests::LookupByLedgerRequest { + ledger_hash: None, + ledger_index: Some(LedgerIndex::Str("validated".into())), + }), + r#type: Some(AccountObjectType::Oracle), + common_fields: xrpl::models::requests::CommonFields { + command: xrpl::models::requests::RequestMethod::AccountObjects, + id: None, + }, + deletion_blockers_only: None, + limit: None, + marker: None, + } + .into(), + ) + .await + .expect("account_objects oracle request failed"); + let result: AccountObjectsResult = response + .try_into() + .expect("failed to parse account_objects result"); + assert!( + result.account_objects.is_empty(), + "Oracle object should be deleted" + ); + }) + .await; +} + +#[tokio::test] +async fn test_oracle_delete_not_found() { + with_blockchain_lock(|| async { + let wallet = generate_funded_wallet().await; + + let mut oracle_delete = OracleDelete { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::OracleDelete, + ..Default::default() + }, + oracle_document_id: 999, // Does not exist + }; + + let engine_result = submit_tx( + &mut oracle_delete, + SubmitOptions { + wallet: &wallet, + autofill: true, + check_fee: true, + }, + ) + .await; + + assert_eq!( + engine_result, "tecNO_ENTRY", + "Deleting a non-existent Oracle (doc_id=999) should return tecNO_ENTRY" + ); + crate::common::ledger_accept().await; + }) + .await; +} diff --git a/tests/transactions/oracle_set.rs b/tests/transactions/oracle_set.rs new file mode 100644 index 00000000..87b28423 --- /dev/null +++ b/tests/transactions/oracle_set.rs @@ -0,0 +1,593 @@ +// 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 crate::common::{ + constants::{ORACLE_ASSET_CLASS, ORACLE_PROVIDER, ORACLE_URI, TEST_ACCOUNT}, + generate_funded_wallet, get_ledger_close_time, submit_tx, test_transaction, + with_blockchain_lock, SubmitOptions, +}; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::requests::account_objects::{AccountObjectType, AccountObjects}; +use xrpl::models::requests::LedgerIndex; +use xrpl::models::results::account_objects::AccountObjects as AccountObjectsResult; +use xrpl::models::transactions::oracle_set::OracleSet; +use xrpl::models::transactions::{CommonFields, PriceData, TransactionType}; +use xrpl::models::Model; + +#[test] +fn test_oracle_set_construction() { + let oracle_set = OracleSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::OracleSet, + fee: Some("12".into()), + sequence: Some(391), + ..Default::default() + }, + oracle_document_id: 1, + provider: Some(ORACLE_PROVIDER.into()), + uri: Some(ORACLE_URI.into()), + asset_class: Some(ORACLE_ASSET_CLASS.into()), + last_update_time: 743609014, + price_data_series: vec![PriceData { + base_asset: "EUR".into(), + quote_asset: "USD".into(), + asset_price: Some("2E4".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.len(), 1); +} + +#[tokio::test] +async fn test_oracle_set_submit() { + with_blockchain_lock(|| async { + let wallet = generate_funded_wallet().await; + // OracleSet LastUpdateTime is POSIX/Unix time. The ledger response uses + // Ripple epoch seconds, so convert before submitting. + let last_update_time = u32::try_from(get_ledger_close_time().await + 946_684_800) + .expect("LastUpdateTime overflows u32: ledger close time is too far in the future"); + + let mut oracle_set = OracleSet { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + oracle_document_id: 1234, + // Provider is a Blob, so it must be hex-encoded ("chainlink"). + provider: Some(ORACLE_PROVIDER.into()), + uri: Some(ORACLE_URI.into()), + asset_class: Some(ORACLE_ASSET_CLASS.into()), + last_update_time, + price_data_series: vec![ + PriceData { + base_asset: "XRP".into(), + quote_asset: "USD".into(), + // AssetPrice is a UInt64 hex string in XRPL binary JSON: 0x2E4 == 740. + asset_price: Some("2E4".into()), + scale: Some(3), + }, + PriceData { + base_asset: "XRP".into(), + quote_asset: "INR".into(), + asset_price: Some("7FFFFFFFFFFFFFFF".into()), + scale: Some(3), + }, + ], + }; + + test_transaction(&mut oracle_set, &wallet).await; + + let client = crate::common::get_client().await; + let response = client + .request( + AccountObjects { + account: wallet.classic_address.clone().into(), + ledger_lookup: Some(xrpl::models::requests::LookupByLedgerRequest { + ledger_hash: None, + ledger_index: Some(LedgerIndex::Str("validated".into())), + }), + r#type: Some(AccountObjectType::Oracle), + common_fields: xrpl::models::requests::CommonFields { + command: xrpl::models::requests::RequestMethod::AccountObjects, + id: None, + }, + deletion_blockers_only: None, + limit: None, + marker: None, + } + .into(), + ) + .await + .expect("account_objects oracle request failed"); + let result: AccountObjectsResult = response + .try_into() + .expect("failed to parse account_objects result"); + assert_eq!(result.account_objects.len(), 1); + let oracle = &result.account_objects[0]; + assert_eq!(oracle["LedgerEntryType"], "Oracle"); + assert_eq!(oracle["Owner"], wallet.classic_address); + assert_eq!(oracle["Provider"], ORACLE_PROVIDER); + assert_eq!(oracle["AssetClass"], ORACLE_ASSET_CLASS); + assert_eq!( + oracle["PriceDataSeries"][0]["PriceData"]["BaseAsset"], + "XRP" + ); + assert_eq!( + oracle["PriceDataSeries"][0]["PriceData"]["QuoteAsset"], + "USD" + ); + assert_eq!( + oracle["PriceDataSeries"][0]["PriceData"]["AssetPrice"], + "2e4" + ); + assert_eq!(oracle["PriceDataSeries"][0]["PriceData"]["Scale"], 3); + assert_eq!( + oracle["PriceDataSeries"][1]["PriceData"]["AssetPrice"], + "7fffffffffffffff" + ); + }) + .await; +} + +#[tokio::test] +async fn test_oracle_set_update_and_delete_pair() { + with_blockchain_lock(|| async { + let wallet = generate_funded_wallet().await; + let last_update_time = u32::try_from(get_ledger_close_time().await + 946_684_800) + .expect("LastUpdateTime overflows u32: ledger close time is too far in the future"); + + let mut oracle_set = OracleSet { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + oracle_document_id: 1234, + provider: Some(ORACLE_PROVIDER.into()), + uri: Some(ORACLE_URI.into()), + asset_class: Some(ORACLE_ASSET_CLASS.into()), + last_update_time, + price_data_series: vec![ + PriceData { + base_asset: "XRP".into(), + quote_asset: "USD".into(), + asset_price: Some("2E4".into()), + scale: Some(3), + }, + PriceData { + base_asset: "XRP".into(), + quote_asset: "EUR".into(), + asset_price: Some("2BC".into()), // 700 + scale: Some(3), + }, + ], + }; + + test_transaction(&mut oracle_set, &wallet).await; + + let mut oracle_update = OracleSet { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + oracle_document_id: 1234, + provider: Some(ORACLE_PROVIDER.into()), + uri: Some(ORACLE_URI.into()), + asset_class: Some(ORACLE_ASSET_CLASS.into()), + last_update_time: last_update_time + 10, + price_data_series: vec![ + // Update XRP/USD + PriceData { + base_asset: "XRP".into(), + quote_asset: "USD".into(), + asset_price: Some("2E5".into()), // 741 + scale: Some(3), + }, + // Delete XRP/EUR by omitting asset_price and scale + PriceData { + base_asset: "XRP".into(), + quote_asset: "EUR".into(), + asset_price: None, + scale: None, + }, + // Add XRP/JPY + PriceData { + base_asset: "XRP".into(), + quote_asset: "JPY".into(), + asset_price: Some("3A98".into()), // 15000 + scale: Some(3), + }, + ], + }; + + test_transaction(&mut oracle_update, &wallet).await; + + let client = crate::common::get_client().await; + let response = client + .request( + AccountObjects { + account: wallet.classic_address.clone().into(), + ledger_lookup: Some(xrpl::models::requests::LookupByLedgerRequest { + ledger_hash: None, + ledger_index: Some(LedgerIndex::Str("validated".into())), + }), + r#type: Some(AccountObjectType::Oracle), + common_fields: xrpl::models::requests::CommonFields { + command: xrpl::models::requests::RequestMethod::AccountObjects, + id: None, + }, + deletion_blockers_only: None, + limit: None, + marker: None, + } + .into(), + ) + .await + .expect("account_objects oracle request failed"); + + let result: AccountObjectsResult = response + .try_into() + .expect("failed to parse account_objects result"); + assert_eq!(result.account_objects.len(), 1); + let oracle = &result.account_objects[0]; + + let series = oracle["PriceDataSeries"] + .as_array() + .expect("PriceDataSeries should be a JSON array in the Oracle ledger entry"); + assert_eq!( + series.len(), + 2, + "Expected 2 pairs after deleting one and adding one" + ); + + // Find XRP/USD pair + let usd_pair = series + .iter() + .find(|p| p["PriceData"]["QuoteAsset"] == "USD") + .expect("XRP/USD pair should be present after update"); + assert_eq!( + usd_pair["PriceData"]["AssetPrice"], "2e5", + "XRP/USD AssetPrice should have been updated to 0x2E5" + ); + + // Find XRP/JPY pair + let jpy_pair = series + .iter() + .find(|p| p["PriceData"]["QuoteAsset"] == "JPY") + .expect("XRP/JPY pair should be present after being added"); + assert_eq!( + jpy_pair["PriceData"]["AssetPrice"], "3a98", + "XRP/JPY AssetPrice should be 0x3A98 (15000)" + ); + + // Ensure XRP/EUR is deleted + let eur_pair = series + .iter() + .find(|p| p["PriceData"]["QuoteAsset"] == "EUR"); + assert!( + eur_pair.is_none(), + "XRP/EUR should have been deleted by omitting AssetPrice/Scale" + ); + }) + .await; +} + +#[tokio::test] +async fn test_oracle_set_tec_token_pair_not_found() { + with_blockchain_lock(|| async { + let wallet = generate_funded_wallet().await; + let last_update_time = u32::try_from(get_ledger_close_time().await + 946_684_800) + .expect("LastUpdateTime overflows u32: ledger close time is too far in the future"); + + let mut oracle_set = OracleSet { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + oracle_document_id: 1, + provider: Some(ORACLE_PROVIDER.into()), + asset_class: Some(ORACLE_ASSET_CLASS.into()), + last_update_time, + price_data_series: vec![PriceData { + base_asset: "XRP".into(), + quote_asset: "USD".into(), + asset_price: Some("2E4".into()), + scale: Some(3), + }], + uri: None, + }; + test_transaction(&mut oracle_set, &wallet).await; + + // Try to delete a token pair (XRP/EUR) that doesn't exist + let mut oracle_update = OracleSet { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + oracle_document_id: 1, + last_update_time: last_update_time + 10, + price_data_series: vec![PriceData { + base_asset: "XRP".into(), + quote_asset: "EUR".into(), + asset_price: None, + scale: None, + }], + provider: None, + asset_class: None, + uri: None, + }; + + let engine_result = submit_tx( + &mut oracle_update, + SubmitOptions { + wallet: &wallet, + autofill: true, + check_fee: true, + }, + ) + .await; + + assert_eq!( + engine_result, "tecTOKEN_PAIR_NOT_FOUND", + "Deleting a pair that does not exist should return tecTOKEN_PAIR_NOT_FOUND" + ); + // Advance the ledger so the consumed sequence number is finalised and + // does not contaminate subsequent tests. + crate::common::ledger_accept().await; + }) + .await; +} + +#[tokio::test] +async fn test_oracle_set_tec_array_empty() { + with_blockchain_lock(|| async { + let wallet = generate_funded_wallet().await; + let last_update_time = u32::try_from(get_ledger_close_time().await + 946_684_800) + .expect("LastUpdateTime overflows u32: ledger close time is too far in the future"); + + let mut oracle_set = OracleSet { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + oracle_document_id: 1, + provider: Some(ORACLE_PROVIDER.into()), + asset_class: Some(ORACLE_ASSET_CLASS.into()), + last_update_time, + price_data_series: vec![PriceData { + base_asset: "XRP".into(), + quote_asset: "USD".into(), + asset_price: Some("2E4".into()), + scale: Some(3), + }], + uri: None, + }; + test_transaction(&mut oracle_set, &wallet).await; + + // Try to delete the only existing token pair (leaves array empty) + let mut oracle_update = OracleSet { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + oracle_document_id: 1, + last_update_time: last_update_time + 10, + price_data_series: vec![PriceData { + base_asset: "XRP".into(), + quote_asset: "USD".into(), + asset_price: None, + scale: None, + }], + provider: None, + asset_class: None, + uri: None, + }; + + let engine_result = submit_tx( + &mut oracle_update, + SubmitOptions { + wallet: &wallet, + autofill: true, + check_fee: true, + }, + ) + .await; + + assert_eq!( + engine_result, + "tecARRAY_EMPTY", + "Deleting the last pair should return tecARRAY_EMPTY (PriceDataSeries cannot be left empty)" + ); + crate::common::ledger_accept().await; + }) + .await; +} + +#[tokio::test] +async fn test_oracle_set_tec_invalid_update_time() { + with_blockchain_lock(|| async { + let wallet = generate_funded_wallet().await; + let last_update_time = u32::try_from(get_ledger_close_time().await + 946_684_800) + .expect("LastUpdateTime overflows u32: ledger close time is too far in the future"); + + let mut oracle_set = OracleSet { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + oracle_document_id: 1, + provider: Some(ORACLE_PROVIDER.into()), + asset_class: Some(ORACLE_ASSET_CLASS.into()), + last_update_time, + price_data_series: vec![PriceData { + base_asset: "XRP".into(), + quote_asset: "USD".into(), + asset_price: Some("2E4".into()), + scale: Some(3), + }], + uri: None, + }; + test_transaction(&mut oracle_set, &wallet).await; + + // Try to update with a LastUpdateTime that is older than the one in the ledger + let mut oracle_update = OracleSet { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + oracle_document_id: 1, + last_update_time: last_update_time - 10, + price_data_series: vec![PriceData { + base_asset: "XRP".into(), + quote_asset: "USD".into(), + asset_price: Some("2E5".into()), + scale: Some(3), + }], + provider: None, + asset_class: None, + uri: None, + }; + + let engine_result = submit_tx( + &mut oracle_update, + SubmitOptions { + wallet: &wallet, + autofill: true, + check_fee: true, + }, + ) + .await; + + assert_eq!( + engine_result, "tecINVALID_UPDATE_TIME", + "LastUpdateTime older than the stored value should return tecINVALID_UPDATE_TIME" + ); + crate::common::ledger_accept().await; + }) + .await; +} + +#[tokio::test] +async fn test_oracle_set_document_id_zero() { + // OracleDocumentID is a UInt32; rippled accepts 0 as a valid value. + with_blockchain_lock(|| async { + let wallet = generate_funded_wallet().await; + let last_update_time = u32::try_from(get_ledger_close_time().await + 946_684_800) + .expect("LastUpdateTime overflows u32: ledger close time is too far in the future"); + + let mut oracle_set = OracleSet { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + oracle_document_id: 0, + provider: Some(ORACLE_PROVIDER.into()), + asset_class: Some(ORACLE_ASSET_CLASS.into()), + last_update_time, + price_data_series: vec![PriceData { + base_asset: "XRP".into(), + quote_asset: "USD".into(), + asset_price: Some("2E4".into()), + scale: Some(3), + }], + uri: None, + }; + test_transaction(&mut oracle_set, &wallet).await; + + let client = crate::common::get_client().await; + let response = client + .request( + AccountObjects { + account: wallet.classic_address.clone().into(), + ledger_lookup: Some(xrpl::models::requests::LookupByLedgerRequest { + ledger_hash: None, + ledger_index: Some(LedgerIndex::Str("validated".into())), + }), + r#type: Some(AccountObjectType::Oracle), + common_fields: xrpl::models::requests::CommonFields { + command: xrpl::models::requests::RequestMethod::AccountObjects, + id: None, + }, + deletion_blockers_only: None, + limit: None, + marker: None, + } + .into(), + ) + .await + .expect("account_objects oracle (doc_id=0) request failed"); + + let result: AccountObjectsResult = response + .try_into() + .expect("failed to parse account_objects result"); + assert_eq!( + result.account_objects.len(), + 1, + "Oracle with OracleDocumentID=0 should exist in ledger" + ); + assert_eq!(result.account_objects[0]["LedgerEntryType"], "Oracle"); + }) + .await; +} + +#[tokio::test] +async fn test_oracle_set_uri_max_byte_boundary() { + // URI is a Blob capped at 256 decoded bytes (kMaxOracleUri = kMaxOracleProvider = 256). + // Exactly 256 decoded bytes = 512 hex chars on the wire. rippled must accept it. + with_blockchain_lock(|| async { + let wallet = generate_funded_wallet().await; + let last_update_time = u32::try_from(get_ledger_close_time().await + 946_684_800) + .expect("LastUpdateTime overflows u32"); + + // 256 bytes of 0xAB = 512 'A'+'B' hex chars — exactly at the limit. + let max_uri = "AB".repeat(256); + + let mut oracle_set = OracleSet { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::OracleSet, + ..Default::default() + }, + oracle_document_id: 1, + provider: Some(ORACLE_PROVIDER.into()), + asset_class: Some(ORACLE_ASSET_CLASS.into()), + last_update_time, + price_data_series: vec![PriceData { + base_asset: "XRP".into(), + quote_asset: "USD".into(), + asset_price: Some("2E4".into()), + scale: Some(3), + }], + uri: Some(max_uri.as_str().into()), + }; + // Model-level validation must accept the boundary value. + oracle_set + .get_errors() + .expect("256-byte URI should pass model validation"); + // And rippled must accept it on-chain. + test_transaction(&mut oracle_set, &wallet).await; + }) + .await; +} diff --git a/tests/transactions/submit_and_wait.rs b/tests/transactions/submit_and_wait.rs index b9c64312..f02cfabf 100644 --- a/tests/transactions/submit_and_wait.rs +++ b/tests/transactions/submit_and_wait.rs @@ -41,7 +41,15 @@ async fn test_submit_and_wait_payment() { .expect("submit_and_wait should return a validated transaction"); ledger_driver.abort(); - let _ = ledger_driver.await; + // Await the task after aborting so we can surface any panic that + // occurred before the abort signal arrived. A cancelled task returns + // Err(JoinError::Cancelled), which we intentionally ignore; anything + // else (i.e. a panic) is re-raised so the test fails with context. + match ledger_driver.await { + Ok(_) => {} + Err(ref e) if e.is_cancelled() => {} + Err(e) => std::panic::resume_unwind(e.into_panic()), + } let metadata = validated_tx .get_transaction_metadata() diff --git a/tests/transactions/sync_wrappers.rs b/tests/transactions/sync_wrappers.rs index a27ba0c5..4c10eb7c 100644 --- a/tests/transactions/sync_wrappers.rs +++ b/tests/transactions/sync_wrappers.rs @@ -182,8 +182,11 @@ fn test_sync_account_root_and_latest_transaction() { let _guard = rt.enter(); // First send a transaction so genesis has a latest transaction to find. + // The funded wallet itself is not needed here — we just need the faucet + // payment to land on-ledger so get_latest_transaction returns a result. rt.block_on(with_blockchain_lock(|| async { - let _ = generate_funded_wallet().await; + let _wallet = generate_funded_wallet().await; + // wallet is intentionally unused; side-effect (ledger tx) is what matters. })); let client = new_client(); @@ -295,7 +298,11 @@ fn test_sync_submit_and_wait_payment() { .expect("sync submit_and_wait"); ledger_driver.abort(); - let _ = ledger_driver.await; + match ledger_driver.await { + Ok(_) => {} + Err(ref e) if e.is_cancelled() => {} + Err(e) => std::panic::resume_unwind(e.into_panic()), + } let metadata = validated .get_transaction_metadata()