Skip to main content

xrpl/models/transactions/
oracle_set.rs

1use alloc::borrow::Cow;
2use alloc::collections::BTreeSet;
3use alloc::vec::Vec;
4use serde::{Deserialize, Serialize};
5use serde_with::skip_serializing_none;
6
7use crate::models::amount::XRPAmount;
8use crate::models::transactions::{Memo, PriceData, Signer, Transaction, TransactionType};
9use crate::models::{FlagCollection, Model, NoFlags, XRPLModelException, XRPLModelResult};
10
11use super::{CommonFields, CommonTransactionBuilder};
12
13/// Maximum number of PriceData entries allowed in a single OracleSet transaction.
14/// Matches rippled `kMaxOracleDataSeries` in `Protocol.h`.
15const MAX_ORACLE_DATA_SERIES: u32 = 10;
16/// Maximum decoded byte length for the `Provider` Blob field.
17/// The hex string on the wire may therefore be up to 512 characters long.
18/// Matches rippled `kMaxOracleProvider = 256` in `Protocol.h`.
19const MAX_ORACLE_PROVIDER_DECODED_BYTES: usize = 256;
20/// Maximum decoded byte length for the `URI` Blob field.
21/// Matches rippled `kMaxOracleUri = 256` in `Protocol.h`.
22const MAX_ORACLE_URI_DECODED_BYTES: usize = 256;
23/// Maximum decoded byte length for the `AssetClass` Blob field.
24/// Matches rippled `kMaxOracleSymbolClass = 16` in `Protocol.h`.
25const MAX_ORACLE_ASSET_CLASS_DECODED_BYTES: usize = 16;
26
27/// An OracleSet transaction creates or updates an Oracle ledger entry.
28///
29/// See OracleSet:
30/// `<https://xrpl.org/docs/references/protocol/transactions/types/oracleset>`
31#[skip_serializing_none]
32#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq, Clone)]
33#[serde(rename_all = "PascalCase")]
34pub struct OracleSet<'a> {
35    /// The base fields for all transaction models.
36    ///
37    /// See Transaction Common Fields:
38    /// `<https://xrpl.org/transaction-common-fields.html>`
39    #[serde(flatten)]
40    pub common_fields: CommonFields<'a, NoFlags>,
41    /// A unique identifier of the price oracle for the account.
42    #[serde(rename = "OracleDocumentID")]
43    pub oracle_document_id: u32,
44    /// An arbitrary value that identifies an oracle provider, such as
45    /// Chainlink, Band, or DIA. This field is a string, up to 256 ASCII
46    /// hex encoded characters (128 bytes).
47    pub provider: Option<Cow<'a, str>>,
48    /// An optional Universal Resource Identifier to reference price data
49    /// off-chain. This field is limited to 256 bytes.
50    #[serde(rename = "URI")]
51    pub uri: Option<Cow<'a, str>>,
52    /// Describes the type of asset, such as "currency", "commodity", or
53    /// "NFT". This field is a string, up to 16 ASCII hex encoded characters
54    /// (8 bytes).
55    pub asset_class: Option<Cow<'a, str>>,
56    /// The time the data was last updated, represented in the ripple epoch.
57    pub last_update_time: u32,
58    /// An array of 1 to 10 PriceData objects, each representing one
59    /// price data entry.
60    pub price_data_series: Vec<PriceData>,
61}
62
63impl Model for OracleSet<'_> {
64    fn get_errors(&self) -> XRPLModelResult<()> {
65        validate_optional_blob(
66            "provider",
67            self.provider.as_deref(),
68            MAX_ORACLE_PROVIDER_DECODED_BYTES,
69        )?;
70        validate_optional_blob("uri", self.uri.as_deref(), MAX_ORACLE_URI_DECODED_BYTES)?;
71        validate_optional_blob(
72            "asset_class",
73            self.asset_class.as_deref(),
74            MAX_ORACLE_ASSET_CLASS_DECODED_BYTES,
75        )?;
76
77        let series = &self.price_data_series;
78        if series.is_empty() {
79            return Err(XRPLModelException::ValueTooLow {
80                field: "price_data_series".into(),
81                min: 1,
82                found: 0,
83            });
84        }
85        if series.len() as u32 > MAX_ORACLE_DATA_SERIES {
86            return Err(XRPLModelException::ValueTooHigh {
87                field: "price_data_series".into(),
88                max: MAX_ORACLE_DATA_SERIES,
89                found: series.len() as u32,
90            });
91        }
92
93        let mut pairs = BTreeSet::new();
94        for entry in series {
95            entry.validate()?;
96            if entry.base_asset == entry.quote_asset {
97                return Err(XRPLModelException::ValueEqualsValue {
98                    field1: "base_asset".into(),
99                    field2: "quote_asset".into(),
100                });
101            }
102            let pair = (entry.base_asset.clone(), entry.quote_asset.clone());
103            if !pairs.insert(pair) {
104                return Err(XRPLModelException::InvalidValue {
105                    field: "price_data_series".into(),
106                    expected: "unique BaseAsset/QuoteAsset pairs".into(),
107                    found: alloc::format!("{}/{}", entry.base_asset, entry.quote_asset),
108                });
109            }
110        }
111        Ok(())
112    }
113}
114
115fn validate_optional_blob(
116    field: &'static str,
117    value: Option<&str>,
118    max_bytes: usize,
119) -> XRPLModelResult<()> {
120    let Some(value) = value else {
121        return Ok(());
122    };
123    let bytes = hex::decode(value).map_err(|e| {
124        use hex::FromHexError;
125        let reason = match e {
126            FromHexError::OddLength => "hex string has odd length (incomplete byte)",
127            FromHexError::InvalidHexCharacter { .. } => "non-hexadecimal character in string",
128            FromHexError::InvalidStringLength => "invalid hex string length",
129        };
130        XRPLModelException::InvalidValue {
131            field: field.into(),
132            expected: alloc::format!("a valid hex-encoded Blob string ({reason})"),
133            found: value.into(),
134        }
135    })?;
136    // rippled `isInvalidLength` rejects empty blobs (length == 0) with
137    // `temMALFORMED`, matching the binary-codec requirement that Blob fields
138    // be non-empty when present.
139    if bytes.is_empty() {
140        return Err(XRPLModelException::InvalidValue {
141            field: field.into(),
142            expected: "a non-empty hex-encoded Blob string (empty strings are rejected)".into(),
143            found: value.into(),
144        });
145    }
146    if bytes.len() > max_bytes {
147        return Err(XRPLModelException::ValueTooLong {
148            field: field.into(),
149            max: max_bytes,
150            found: bytes.len(),
151        });
152    }
153    Ok(())
154}
155
156impl<'a> Transaction<'a, NoFlags> for OracleSet<'a> {
157    fn get_transaction_type(&self) -> &TransactionType {
158        self.common_fields.get_transaction_type()
159    }
160
161    fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> {
162        self.common_fields.get_common_fields()
163    }
164
165    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
166        self.common_fields.get_mut_common_fields()
167    }
168}
169
170impl<'a> CommonTransactionBuilder<'a, NoFlags> for OracleSet<'a> {
171    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
172        &mut self.common_fields
173    }
174
175    fn into_self(self) -> Self {
176        self
177    }
178}
179
180impl<'a> OracleSet<'a> {
181    pub fn new(
182        account: Cow<'a, str>,
183        account_txn_id: Option<Cow<'a, str>>,
184        fee: Option<XRPAmount<'a>>,
185        last_ledger_sequence: Option<u32>,
186        memos: Option<Vec<Memo>>,
187        sequence: Option<u32>,
188        signers: Option<Vec<Signer>>,
189        source_tag: Option<u32>,
190        ticket_sequence: Option<u32>,
191        oracle_document_id: u32,
192        provider: Option<Cow<'a, str>>,
193        uri: Option<Cow<'a, str>>,
194        asset_class: Option<Cow<'a, str>>,
195        last_update_time: u32,
196        price_data_series: Vec<PriceData>,
197    ) -> Self {
198        Self {
199            common_fields: CommonFields {
200                account,
201                transaction_type: TransactionType::OracleSet,
202                account_txn_id,
203                fee,
204                flags: FlagCollection::default(),
205                last_ledger_sequence,
206                memos,
207                network_id: None,
208                sequence,
209                signers,
210                signing_pub_key: None, // filled by the signing layer
211                source_tag,
212                ticket_sequence,
213                txn_signature: None, // filled by the signing layer
214            },
215            oracle_document_id,
216            provider,
217            uri,
218            asset_class,
219            last_update_time,
220            price_data_series,
221        }
222    }
223
224    /// Set the oracle document ID
225    pub fn with_oracle_document_id(mut self, id: u32) -> Self {
226        self.oracle_document_id = id;
227        self
228    }
229
230    /// Set the provider
231    pub fn with_provider(mut self, provider: Cow<'a, str>) -> Self {
232        self.provider = Some(provider);
233        self
234    }
235
236    /// Set the URI
237    pub fn with_uri(mut self, uri: Cow<'a, str>) -> Self {
238        self.uri = Some(uri);
239        self
240    }
241
242    /// Set the asset class
243    pub fn with_asset_class(mut self, asset_class: Cow<'a, str>) -> Self {
244        self.asset_class = Some(asset_class);
245        self
246    }
247
248    /// Set the last update time
249    pub fn with_last_update_time(mut self, time: u32) -> Self {
250        self.last_update_time = time;
251        self
252    }
253
254    /// Set the price data series
255    pub fn with_price_data_series(mut self, series: Vec<PriceData>) -> Self {
256        self.price_data_series = series;
257        self
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use alloc::string::ToString;
265    use alloc::vec;
266
267    /// Canonical test account used across all OracleSet unit tests.
268    const TEST_ACCOUNT: &str = "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW";
269    const TEST_FEE: &str = "12";
270    const TEST_SEQUENCE: u32 = 391;
271    const TEST_LAST_LEDGER: u32 = 596447;
272    const TEST_DOC_ID: u32 = 1;
273    const TEST_LAST_UPDATE_TIME: u32 = 743609014;
274    /// "chainlink" hex-encoded (Provider is a Blob field).
275    const TEST_PROVIDER: &str = "636861696E6C696E6B";
276    /// "currency" hex-encoded (AssetClass is a Blob field).
277    const TEST_ASSET_CLASS: &str = "63757272656E6379";
278
279    #[test]
280    fn test_serde() {
281        let oracle_set = OracleSet {
282            common_fields: CommonFields {
283                account: TEST_ACCOUNT.into(),
284                transaction_type: TransactionType::OracleSet,
285                fee: Some("12".into()),
286                sequence: Some(391),
287                signing_pub_key: Some("".into()),
288                ..Default::default()
289            },
290            oracle_document_id: 1,
291            provider: Some("636861696E6C696E6B".into()),
292            uri: Some("68747470733A2F2F6578616D706C652E636F6D2F6F7261636C6531".into()),
293            asset_class: Some("63757272656E6379".into()),
294            last_update_time: 743609014,
295            price_data_series: vec![PriceData {
296                base_asset: "EUR".to_string(),
297                quote_asset: "USD".to_string(),
298                asset_price: Some("740".to_string()), // hex: 1856 decimal,
299                scale: Some(1),
300            }],
301        };
302
303        let serialized = serde_json::to_string(&oracle_set)
304            .expect("OracleSet should serialize to JSON without error");
305        let deserialized: OracleSet = serde_json::from_str(&serialized)
306            .expect("OracleSet should deserialize from its own JSON output");
307        assert_eq!(oracle_set, deserialized);
308        // `XRP` was rejected as a PriceData asset; ensure this model validates.
309        assert!(oracle_set.get_errors().is_ok());
310    }
311
312    #[test]
313    fn test_builder_pattern() {
314        let oracle_set = OracleSet {
315            common_fields: CommonFields {
316                account: TEST_ACCOUNT.into(),
317                transaction_type: TransactionType::OracleSet,
318                ..Default::default()
319            },
320            ..Default::default()
321        }
322        .with_oracle_document_id(1)
323        .with_provider("chainlink".into())
324        .with_uri("https://example.com".into())
325        .with_asset_class("63757272656E6379".into())
326        .with_last_update_time(743609014)
327        .with_fee("12".into())
328        .with_sequence(100)
329        .with_last_ledger_sequence(596447)
330        .with_source_tag(42);
331
332        assert_eq!(oracle_set.oracle_document_id, 1);
333        assert_eq!(oracle_set.provider.as_deref(), Some("chainlink"));
334        assert_eq!(oracle_set.uri.as_deref(), Some("https://example.com"));
335        assert_eq!(oracle_set.asset_class.as_deref(), Some("63757272656E6379"));
336        assert_eq!(oracle_set.last_update_time, 743609014);
337        assert_eq!(oracle_set.common_fields.fee.as_ref().unwrap().0, "12");
338        assert_eq!(oracle_set.common_fields.sequence, Some(100));
339        assert_eq!(oracle_set.common_fields.last_ledger_sequence, Some(596447));
340        assert_eq!(oracle_set.common_fields.source_tag, Some(42));
341    }
342
343    #[test]
344    fn test_default() {
345        let oracle_set = OracleSet {
346            common_fields: CommonFields {
347                account: TEST_ACCOUNT.into(),
348                transaction_type: TransactionType::OracleSet,
349                ..Default::default()
350            },
351            ..Default::default()
352        };
353
354        assert_eq!(oracle_set.common_fields.account, TEST_ACCOUNT);
355        assert_eq!(
356            oracle_set.common_fields.transaction_type,
357            TransactionType::OracleSet
358        );
359        assert_eq!(oracle_set.oracle_document_id, 0);
360        assert!(oracle_set.provider.is_none());
361        assert!(oracle_set.uri.is_none());
362        assert!(oracle_set.asset_class.is_none());
363        assert_eq!(oracle_set.last_update_time, 0);
364        assert!(oracle_set.price_data_series.is_empty());
365    }
366
367    #[test]
368    fn test_with_price_data() {
369        let price_data = vec![
370            PriceData {
371                base_asset: "EUR".to_string(),
372                quote_asset: "USD".to_string(),
373                asset_price: Some("740".to_string()), // hex: 1856 decimal,
374                scale: Some(1),
375            },
376            PriceData {
377                base_asset: "BTC".to_string(),
378                quote_asset: "USD".to_string(),
379                asset_price: Some("2600000".to_string()), // hex: 39845888 decimal,
380                scale: Some(2),
381            },
382        ];
383
384        let oracle_set = OracleSet {
385            common_fields: CommonFields {
386                account: TEST_ACCOUNT.into(),
387                transaction_type: TransactionType::OracleSet,
388                ..Default::default()
389            },
390            ..Default::default()
391        }
392        .with_price_data_series(price_data.clone());
393
394        let series = oracle_set.price_data_series;
395        assert_eq!(series.len(), 2);
396        assert_eq!(series[0].base_asset, "EUR");
397        assert_eq!(series[0].quote_asset, "USD");
398        assert_eq!(series[0].asset_price.as_deref(), Some("740"));
399        assert_eq!(series[0].scale, Some(1));
400        assert_eq!(series[1].base_asset, "BTC");
401    }
402
403    #[test]
404    fn test_minimal() {
405        let oracle_set = OracleSet {
406            common_fields: CommonFields {
407                account: TEST_ACCOUNT.into(),
408                transaction_type: TransactionType::OracleSet,
409                ..Default::default()
410            },
411            oracle_document_id: TEST_DOC_ID,
412            last_update_time: TEST_LAST_UPDATE_TIME,
413            price_data_series: vec![],
414            ..Default::default()
415        };
416
417        assert_eq!(oracle_set.common_fields.account, TEST_ACCOUNT);
418        assert_eq!(
419            oracle_set.common_fields.transaction_type,
420            TransactionType::OracleSet
421        );
422        assert_eq!(oracle_set.oracle_document_id, TEST_DOC_ID);
423    }
424
425    #[test]
426    fn test_new_constructor() {
427        let oracle_set = OracleSet {
428            common_fields: CommonFields {
429                account: TEST_ACCOUNT.into(),
430                transaction_type: TransactionType::OracleSet,
431                fee: Some(TEST_FEE.into()),
432                last_ledger_sequence: Some(TEST_LAST_LEDGER),
433                sequence: Some(TEST_SEQUENCE),
434                ..Default::default()
435            },
436            oracle_document_id: TEST_DOC_ID,
437            // Non-hex plain string used here intentionally to test that the
438            // constructor stores values verbatim (validation is in get_errors).
439            provider: Some("chainlink".into()),
440            uri: Some("68747470733A2F2F6578616D706C652E636F6D2F6F7261636C6531".into()),
441            asset_class: Some(TEST_ASSET_CLASS.into()),
442            last_update_time: TEST_LAST_UPDATE_TIME,
443            price_data_series: vec![PriceData {
444                base_asset: "EUR".to_string(),
445                quote_asset: "USD".to_string(),
446                asset_price: Some("2E4".to_string()), // hex: 740 decimal,
447                scale: Some(1),
448            }],
449        };
450
451        assert_eq!(
452            oracle_set.common_fields.transaction_type,
453            TransactionType::OracleSet
454        );
455        assert_eq!(oracle_set.common_fields.fee, Some(TEST_FEE.into()));
456        assert_eq!(oracle_set.common_fields.sequence, Some(TEST_SEQUENCE));
457        assert_eq!(oracle_set.oracle_document_id, TEST_DOC_ID);
458        assert_eq!(oracle_set.provider.as_deref(), Some("chainlink"));
459        assert_eq!(oracle_set.last_update_time, TEST_LAST_UPDATE_TIME);
460        assert_eq!(oracle_set.price_data_series.len(), 1);
461    }
462
463    #[test]
464    fn test_transaction_type() {
465        let oracle_set = OracleSet {
466            common_fields: CommonFields {
467                account: TEST_ACCOUNT.into(),
468                transaction_type: TransactionType::OracleSet,
469                ..Default::default()
470            },
471            ..Default::default()
472        };
473
474        assert_eq!(
475            *oracle_set.get_transaction_type(),
476            TransactionType::OracleSet
477        );
478    }
479
480    #[test]
481    fn test_with_memos() {
482        let oracle_set = OracleSet {
483            common_fields: CommonFields {
484                account: TEST_ACCOUNT.into(),
485                transaction_type: TransactionType::OracleSet,
486                ..Default::default()
487            },
488            ..Default::default()
489        }
490        .with_oracle_document_id(1)
491        .with_memo(Memo {
492            memo_data: Some("oracle update".into()),
493            memo_format: None,
494            memo_type: Some("text".into()),
495        });
496
497        assert_eq!(oracle_set.common_fields.memos.as_ref().unwrap().len(), 1);
498    }
499
500    #[test]
501    fn test_empty_price_data_series_rejected() {
502        // When `price_data_series` is present, rippled requires at least 1 entry.
503        let oracle_set = OracleSet {
504            common_fields: CommonFields {
505                account: TEST_ACCOUNT.into(),
506                transaction_type: TransactionType::OracleSet,
507                ..Default::default()
508            },
509            ..Default::default()
510        }
511        .with_price_data_series(vec![]);
512
513        let err = oracle_set.get_errors().unwrap_err();
514        assert_eq!(
515            err,
516            XRPLModelException::ValueTooLow {
517                field: "price_data_series".into(),
518                min: 1,
519                found: 0,
520            }
521        );
522    }
523
524    #[test]
525    fn test_price_data_optional_update_fields() {
526        // BaseAsset and QuoteAsset are required protocol fields. AssetPrice and
527        // Scale remain optional; omitting AssetPrice on update deletes the pair.
528        let price_data = PriceData {
529            base_asset: "EUR".to_string(),
530            quote_asset: "USD".to_string(),
531            asset_price: None,
532            scale: None,
533        };
534
535        let oracle_set = OracleSet {
536            common_fields: CommonFields {
537                account: TEST_ACCOUNT.into(),
538                transaction_type: TransactionType::OracleSet,
539                ..Default::default()
540            },
541            ..Default::default()
542        }
543        .with_price_data_series(vec![price_data]);
544
545        let series = oracle_set.price_data_series;
546        assert_eq!(series[0].base_asset, "EUR");
547        assert_eq!(series[0].quote_asset, "USD");
548        assert!(series[0].asset_price.is_none());
549        assert!(series[0].scale.is_none());
550    }
551
552    #[test]
553    fn test_price_data_series_max_valid() {
554        // Use valid 3-char ISO-style codes for the per-entry currency validation.
555        let series: Vec<PriceData> = (0..10)
556            .map(|i| PriceData {
557                base_asset: alloc::format!("A{i:02}"),
558                quote_asset: "USD".to_string(),
559                asset_price: Some("100".to_string()), // hex: 256 decimal,
560                scale: Some(1),
561            })
562            .collect();
563
564        let oracle_set = OracleSet {
565            common_fields: CommonFields {
566                account: TEST_ACCOUNT.into(),
567                transaction_type: TransactionType::OracleSet,
568                ..Default::default()
569            },
570            ..Default::default()
571        }
572        .with_price_data_series(series);
573
574        assert!(oracle_set.get_errors().is_ok());
575    }
576
577    #[test]
578    fn test_price_data_series_exceeds_max() {
579        let series: Vec<PriceData> = (0..11)
580            .map(|i| PriceData {
581                base_asset: alloc::format!("A{i:02}"),
582                quote_asset: "USD".to_string(),
583                asset_price: Some("100".to_string()), // hex: 256 decimal,
584                scale: Some(1),
585            })
586            .collect();
587
588        let oracle_set = OracleSet {
589            common_fields: CommonFields {
590                account: TEST_ACCOUNT.into(),
591                transaction_type: TransactionType::OracleSet,
592                ..Default::default()
593            },
594            ..Default::default()
595        }
596        .with_price_data_series(series);
597
598        let err = oracle_set.get_errors().unwrap_err();
599        assert_eq!(
600            err,
601            XRPLModelException::ValueTooHigh {
602                field: "price_data_series".into(),
603                max: 10,
604                found: 11,
605            }
606        );
607    }
608
609    #[test]
610    fn test_scale_too_high_rejected() {
611        // Per rippled `kMaxPriceScale = 20` in Protocol.h; scale 21 is rejected.
612        let oracle_set = OracleSet {
613            common_fields: CommonFields {
614                account: TEST_ACCOUNT.into(),
615                transaction_type: TransactionType::OracleSet,
616                ..Default::default()
617            },
618            ..Default::default()
619        }
620        .with_price_data_series(vec![PriceData {
621            base_asset: "EUR".to_string(),
622            quote_asset: "USD".to_string(),
623            asset_price: Some("100".to_string()), // hex: 256 decimal,
624            scale: Some(21),
625        }]);
626
627        let err = oracle_set.get_errors().unwrap_err();
628        assert_eq!(
629            err,
630            XRPLModelException::ValueTooHigh {
631                field: "scale".into(),
632                max: 20,
633                found: 21,
634            }
635        );
636    }
637
638    #[test]
639    fn test_scale_at_max_ok() {
640        // Boundary: scale = 20 is explicitly permitted (kMaxPriceScale = 20).
641        let oracle_set = OracleSet {
642            common_fields: CommonFields {
643                account: TEST_ACCOUNT.into(),
644                transaction_type: TransactionType::OracleSet,
645                ..Default::default()
646            },
647            ..Default::default()
648        }
649        .with_price_data_series(vec![PriceData {
650            base_asset: "EUR".to_string(),
651            quote_asset: "USD".to_string(),
652            asset_price: Some("100".to_string()), // hex: 256 decimal,
653            scale: Some(20),
654        }]);
655
656        assert!(oracle_set.get_errors().is_ok());
657    }
658
659    #[test]
660    fn test_scale_mid_range_ok() {
661        // Values 11-20 must also pass; they were incorrectly rejected before.
662        let oracle_set = OracleSet {
663            common_fields: CommonFields {
664                account: TEST_ACCOUNT.into(),
665                transaction_type: TransactionType::OracleSet,
666                ..Default::default()
667            },
668            ..Default::default()
669        }
670        .with_price_data_series(vec![PriceData {
671            base_asset: "EUR".to_string(),
672            quote_asset: "USD".to_string(),
673            asset_price: Some("100".to_string()), // hex: 256 decimal,
674            scale: Some(15),
675        }]);
676
677        assert!(oracle_set.get_errors().is_ok());
678    }
679
680    #[test]
681    fn test_asset_price_and_scale_must_be_paired() {
682        let oracle_set = OracleSet {
683            common_fields: CommonFields {
684                account: TEST_ACCOUNT.into(),
685                transaction_type: TransactionType::OracleSet,
686                ..Default::default()
687            },
688            ..Default::default()
689        }
690        .with_price_data_series(vec![PriceData {
691            base_asset: "XRP".to_string(),
692            quote_asset: "USD".to_string(),
693            asset_price: Some("100".to_string()), // hex: 256 decimal,
694            scale: None,
695        }]);
696
697        assert!(matches!(
698            oracle_set.get_errors().unwrap_err(),
699            XRPLModelException::InvalidValue { ref field, .. } if field == "price_data"
700        ));
701    }
702
703    #[test]
704    fn test_invalid_base_asset_rejected() {
705        // A 4-character code is neither a valid ISO code nor a 40-char hex.
706        let oracle_set = OracleSet {
707            common_fields: CommonFields {
708                account: TEST_ACCOUNT.into(),
709                transaction_type: TransactionType::OracleSet,
710                ..Default::default()
711            },
712            ..Default::default()
713        }
714        .with_price_data_series(vec![PriceData {
715            base_asset: "EURO".to_string(),
716            quote_asset: "USD".to_string(),
717            asset_price: Some("100".to_string()), // hex: 256 decimal,
718            scale: Some(1),
719        }]);
720
721        let err = oracle_set.get_errors().unwrap_err();
722        assert!(matches!(
723            err,
724            XRPLModelException::InvalidValue { ref field, .. } if field == "base_asset"
725        ));
726    }
727
728    #[test]
729    fn test_xrp_as_asset_accepted() {
730        // XRP is valid as an oracle currency code.
731        let oracle_set = OracleSet {
732            common_fields: CommonFields {
733                account: TEST_ACCOUNT.into(),
734                transaction_type: TransactionType::OracleSet,
735                ..Default::default()
736            },
737            ..Default::default()
738        }
739        .with_price_data_series(vec![PriceData {
740            base_asset: "XRP".to_string(),
741            quote_asset: "USD".to_string(),
742            asset_price: Some("100".to_string()), // hex: 256 decimal,
743            scale: Some(1),
744        }]);
745
746        assert!(oracle_set.get_errors().is_ok());
747    }
748
749    #[test]
750    fn test_hex_currency_accepted() {
751        // 40-character hex currency codes are valid.
752        let oracle_set = OracleSet {
753            common_fields: CommonFields {
754                account: TEST_ACCOUNT.into(),
755                transaction_type: TransactionType::OracleSet,
756                ..Default::default()
757            },
758            ..Default::default()
759        }
760        .with_price_data_series(vec![PriceData {
761            base_asset: "0000000000000000000000005553440000000000".to_string(),
762            quote_asset: "USD".to_string(),
763            asset_price: Some("100".to_string()), // hex: 256 decimal,
764            scale: Some(0),
765        }]);
766
767        assert!(oracle_set.get_errors().is_ok());
768    }
769
770    #[test]
771    fn test_oracle_metadata_lengths_rejected() {
772        let oracle_set = OracleSet {
773            common_fields: CommonFields {
774                account: TEST_ACCOUNT.into(),
775                transaction_type: TransactionType::OracleSet,
776                ..Default::default()
777            },
778            provider: Some("AA".repeat(MAX_ORACLE_PROVIDER_DECODED_BYTES + 1).into()),
779            price_data_series: vec![PriceData {
780                base_asset: "XRP".to_string(),
781                quote_asset: "USD".to_string(),
782                asset_price: Some("100".to_string()), // hex: 256 decimal,
783                scale: Some(1),
784            }],
785            ..Default::default()
786        };
787
788        assert!(matches!(
789            oracle_set.get_errors().unwrap_err(),
790            XRPLModelException::ValueTooLong { ref field, max, .. }
791                if field == "provider" && max == MAX_ORACLE_PROVIDER_DECODED_BYTES
792        ));
793    }
794
795    #[test]
796    fn test_oracle_metadata_must_be_hex() {
797        let oracle_set = OracleSet {
798            common_fields: CommonFields {
799                account: TEST_ACCOUNT.into(),
800                transaction_type: TransactionType::OracleSet,
801                ..Default::default()
802            },
803            provider: Some("chainlink".into()),
804            price_data_series: vec![PriceData {
805                base_asset: "XRP".to_string(),
806                quote_asset: "USD".to_string(),
807                asset_price: Some("100".to_string()), // hex: 256 decimal,
808                scale: Some(1),
809            }],
810            ..Default::default()
811        };
812
813        assert!(matches!(
814            oracle_set.get_errors().unwrap_err(),
815            XRPLModelException::InvalidValue { ref field, .. } if field == "provider"
816        ));
817    }
818
819    #[test]
820    fn test_asset_price_full_u64_range_accepted() {
821        // AssetPrice is a plain UInt64 — the full unsigned range is valid,
822        // including 0x8000000000000000..=0xFFFFFFFFFFFFFFFF.
823        // xrpl.js integration test uses "ffffffffffffffff" successfully.
824        for price in ["8000000000000000", "FFFFFFFFFFFFFFFF", "1", "0"] {
825            let oracle_set = OracleSet {
826                common_fields: CommonFields {
827                    account: TEST_ACCOUNT.into(),
828                    transaction_type: TransactionType::OracleSet,
829                    ..Default::default()
830                },
831                ..Default::default()
832            }
833            .with_price_data_series(vec![PriceData {
834                base_asset: "XRP".to_string(),
835                quote_asset: "USD".to_string(),
836                asset_price: Some(price.to_string()),
837                scale: Some(1),
838            }]);
839
840            assert!(
841                oracle_set.get_errors().is_ok(),
842                "AssetPrice {price} should be valid"
843            );
844        }
845    }
846
847    #[test]
848    fn test_asset_price_non_hex_rejected() {
849        let oracle_set = OracleSet {
850            common_fields: CommonFields {
851                account: TEST_ACCOUNT.into(),
852                transaction_type: TransactionType::OracleSet,
853                ..Default::default()
854            },
855            ..Default::default()
856        }
857        .with_price_data_series(vec![PriceData {
858            base_asset: "XRP".to_string(),
859            quote_asset: "USD".to_string(),
860            asset_price: Some("ZZZZZZZZZZZZZZZZ".to_string()),
861            scale: Some(1),
862        }]);
863
864        assert!(matches!(
865            oracle_set.get_errors().unwrap_err(),
866            XRPLModelException::InvalidValue { ref field, .. } if field == "asset_price"
867        ));
868    }
869
870    #[test]
871    fn test_empty_blob_fields_rejected() {
872        // rippled rejects zero-length Provider/URI/AssetClass with temMALFORMED.
873        for (field_name, oracle) in [
874            (
875                "provider",
876                OracleSet {
877                    common_fields: CommonFields {
878                        account: TEST_ACCOUNT.into(),
879                        transaction_type: TransactionType::OracleSet,
880                        ..Default::default()
881                    },
882                    provider: Some("".into()),
883                    price_data_series: vec![PriceData {
884                        base_asset: "XRP".to_string(),
885                        quote_asset: "USD".to_string(),
886                        asset_price: Some("100".to_string()), // hex: 256 decimal,
887                        scale: Some(1),
888                    }],
889                    ..Default::default()
890                },
891            ),
892            (
893                "uri",
894                OracleSet {
895                    common_fields: CommonFields {
896                        account: TEST_ACCOUNT.into(),
897                        transaction_type: TransactionType::OracleSet,
898                        ..Default::default()
899                    },
900                    uri: Some("".into()),
901                    price_data_series: vec![PriceData {
902                        base_asset: "XRP".to_string(),
903                        quote_asset: "USD".to_string(),
904                        asset_price: Some("100".to_string()), // hex: 256 decimal,
905                        scale: Some(1),
906                    }],
907                    ..Default::default()
908                },
909            ),
910            (
911                "asset_class",
912                OracleSet {
913                    common_fields: CommonFields {
914                        account: TEST_ACCOUNT.into(),
915                        transaction_type: TransactionType::OracleSet,
916                        ..Default::default()
917                    },
918                    asset_class: Some("".into()),
919                    price_data_series: vec![PriceData {
920                        base_asset: "XRP".to_string(),
921                        quote_asset: "USD".to_string(),
922                        asset_price: Some("100".to_string()), // hex: 256 decimal,
923                        scale: Some(1),
924                    }],
925                    ..Default::default()
926                },
927            ),
928        ] {
929            assert!(
930                matches!(
931                    oracle.get_errors().unwrap_err(),
932                    XRPLModelException::InvalidValue { ref field, .. } if field == field_name
933                ),
934                "empty {field_name} should be rejected"
935            );
936        }
937    }
938
939    #[test]
940    fn test_duplicate_price_data_pair_rejected() {
941        let oracle_set = OracleSet {
942            common_fields: CommonFields {
943                account: TEST_ACCOUNT.into(),
944                transaction_type: TransactionType::OracleSet,
945                ..Default::default()
946            },
947            ..Default::default()
948        }
949        .with_price_data_series(vec![
950            PriceData {
951                base_asset: "XRP".to_string(),
952                quote_asset: "USD".to_string(),
953                asset_price: Some("100".to_string()), // hex: 256 decimal,
954                scale: Some(1),
955            },
956            PriceData {
957                base_asset: "XRP".to_string(),
958                quote_asset: "USD".to_string(),
959                asset_price: Some("101".to_string()),
960                scale: Some(1),
961            },
962        ]);
963
964        assert!(matches!(
965            oracle_set.get_errors().unwrap_err(),
966            XRPLModelException::InvalidValue { ref field, .. } if field == "price_data_series"
967        ));
968    }
969
970    #[test]
971    fn test_same_base_quote_rejected() {
972        let oracle_set = OracleSet {
973            common_fields: CommonFields {
974                account: TEST_ACCOUNT.into(),
975                transaction_type: TransactionType::OracleSet,
976                ..Default::default()
977            },
978            ..Default::default()
979        }
980        .with_price_data_series(vec![PriceData {
981            base_asset: "XRP".to_string(),
982            quote_asset: "XRP".to_string(),
983            asset_price: Some("100".to_string()), // hex: 256 decimal,
984            scale: Some(1),
985        }]);
986
987        assert!(matches!(
988            oracle_set.get_errors().unwrap_err(),
989            XRPLModelException::ValueEqualsValue { ref field1, ref field2 }
990                if field1 == "base_asset" && field2 == "quote_asset"
991        ));
992    }
993}