Skip to content

Commit 599810f

Browse files
committed
fix(oracle): align submit tests with rippled validation
1 parent d706f54 commit 599810f

4 files changed

Lines changed: 150 additions & 31 deletions

File tree

src/models/transactions/mod.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -594,8 +594,8 @@ pub struct PriceData {
594594

595595
/// Maximum allowed value for the `scale` field of a `PriceData` entry.
596596
///
597-
/// Per rippled, `scale` must be in the inclusive range `0..=20`.
598-
pub const MAX_PRICE_DATA_SCALE: u8 = 20;
597+
/// Per rippled, `scale` must be in the inclusive range `0..=10`.
598+
pub const MAX_PRICE_DATA_SCALE: u8 = 10;
599599

600600
impl crate::models::Model for PriceData {
601601
fn get_errors(&self) -> crate::models::XRPLModelResult<()> {
@@ -608,12 +608,29 @@ impl crate::models::Model for PriceData {
608608
});
609609
}
610610
}
611+
if self.asset_price.is_some() != self.scale.is_some() {
612+
return Err(crate::models::XRPLModelException::InvalidValue {
613+
field: "price_data".into(),
614+
expected: "AssetPrice and Scale both present or both omitted".into(),
615+
found: alloc::format!(
616+
"asset_price_present={}, scale_present={}",
617+
self.asset_price.is_some(),
618+
self.scale.is_some()
619+
),
620+
});
621+
}
611622
validate_oracle_currency("base_asset", &self.base_asset)?;
612623
validate_oracle_currency("quote_asset", &self.quote_asset)?;
624+
validate_oracle_asset_price(&self.asset_price)?;
613625
Ok(())
614626
}
615627
}
616628

629+
/// Maximum allowed value for `AssetPrice`.
630+
///
631+
/// `rippled` rejects prices above signed i64::MAX with `temBAD_PRICE`.
632+
pub const MAX_ORACLE_ASSET_PRICE: u64 = 0x7FFF_FFFF_FFFF_FFFF;
633+
617634
/// Validate a currency code used in a `PriceData` entry.
618635
///
619636
/// Accepts either a 3-character ISO-style code (uppercase letters and digits,
@@ -633,6 +650,25 @@ fn validate_oracle_currency(
633650
})
634651
}
635652

653+
fn validate_oracle_asset_price(value: &Option<String>) -> crate::models::XRPLModelResult<()> {
654+
let Some(value) = value else {
655+
return Ok(());
656+
};
657+
match u64::from_str_radix(value, 16) {
658+
Ok(price) if price <= MAX_ORACLE_ASSET_PRICE => Ok(()),
659+
Ok(_) => Err(crate::models::XRPLModelException::InvalidValue {
660+
field: "asset_price".into(),
661+
expected: "a UInt64 hexadecimal string no greater than 0x7FFFFFFFFFFFFFFF".into(),
662+
found: value.clone(),
663+
}),
664+
Err(_) => Err(crate::models::XRPLModelException::InvalidValue {
665+
field: "asset_price".into(),
666+
expected: "a UInt64 hexadecimal string no greater than 0x7FFFFFFFFFFFFFFF".into(),
667+
found: value.clone(),
668+
}),
669+
}
670+
}
671+
636672
/// Standard functions for transactions.
637673
pub trait Transaction<'a, T>
638674
where

src/models/transactions/oracle_set.rs

Lines changed: 98 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,13 @@ pub struct OracleSet<'a> {
5454

5555
impl Model for OracleSet<'_> {
5656
fn get_errors(&self) -> XRPLModelResult<()> {
57-
validate_optional_length(
57+
validate_optional_blob(
5858
"provider",
5959
self.provider.as_deref(),
6060
MAX_ORACLE_PROVIDER_BYTES,
6161
)?;
62-
validate_optional_length("uri", self.uri.as_deref(), MAX_ORACLE_URI_BYTES)?;
63-
validate_optional_length(
62+
validate_optional_blob("uri", self.uri.as_deref(), MAX_ORACLE_URI_BYTES)?;
63+
validate_optional_blob(
6464
"asset_class",
6565
self.asset_class.as_deref(),
6666
MAX_ORACLE_ASSET_CLASS_BYTES,
@@ -104,20 +104,25 @@ impl Model for OracleSet<'_> {
104104
}
105105
}
106106

107-
fn validate_optional_length(
107+
fn validate_optional_blob(
108108
field: &'static str,
109109
value: Option<&str>,
110-
max: usize,
110+
max_bytes: usize,
111111
) -> XRPLModelResult<()> {
112-
if let Some(value) = value {
113-
let found = value.len();
114-
if found > max {
115-
return Err(XRPLModelException::ValueTooLong {
116-
field: field.into(),
117-
max,
118-
found,
119-
});
120-
}
112+
let Some(value) = value else {
113+
return Ok(());
114+
};
115+
let bytes = hex::decode(value).map_err(|_| XRPLModelException::InvalidValue {
116+
field: field.into(),
117+
expected: "a hex-encoded Blob string".into(),
118+
found: value.into(),
119+
})?;
120+
if bytes.len() > max_bytes {
121+
return Err(XRPLModelException::ValueTooLong {
122+
field: field.into(),
123+
max: max_bytes,
124+
found: bytes.len(),
125+
});
121126
}
122127
Ok(())
123128
}
@@ -245,8 +250,8 @@ mod tests {
245250
..Default::default()
246251
},
247252
oracle_document_id: 1,
248-
provider: Some("chainlink".into()),
249-
uri: Some("https://example.com/oracle1".into()),
253+
provider: Some("636861696E6C696E6B".into()),
254+
uri: Some("68747470733A2F2F6578616D706C652E636F6D2F6F7261636C6531".into()),
250255
asset_class: Some("63757272656E6379".into()),
251256
last_update_time: 743609014,
252257
price_data_series: vec![PriceData {
@@ -576,7 +581,7 @@ mod tests {
576581

577582
#[test]
578583
fn test_scale_too_high_rejected() {
579-
// Per rippled, `scale` must be in the inclusive range 0..=20.
584+
// Per rippled, `scale` must be in the inclusive range 0..=10.
580585
let oracle_set = OracleSet {
581586
common_fields: CommonFields {
582587
account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(),
@@ -589,23 +594,23 @@ mod tests {
589594
base_asset: "EUR".to_string(),
590595
quote_asset: "USD".to_string(),
591596
asset_price: Some("100".to_string()),
592-
scale: Some(21),
597+
scale: Some(11),
593598
}]);
594599

595600
let err = oracle_set.get_errors().unwrap_err();
596601
assert_eq!(
597602
err,
598603
XRPLModelException::ValueTooHigh {
599604
field: "scale".into(),
600-
max: 20,
601-
found: 21,
605+
max: 10,
606+
found: 11,
602607
}
603608
);
604609
}
605610

606611
#[test]
607612
fn test_scale_at_max_ok() {
608-
// Boundary: scale = 20 is explicitly permitted.
613+
// Boundary: scale = 10 is explicitly permitted.
609614
let oracle_set = OracleSet {
610615
common_fields: CommonFields {
611616
account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(),
@@ -618,12 +623,35 @@ mod tests {
618623
base_asset: "EUR".to_string(),
619624
quote_asset: "USD".to_string(),
620625
asset_price: Some("100".to_string()),
621-
scale: Some(20),
626+
scale: Some(10),
622627
}]);
623628

624629
assert!(oracle_set.get_errors().is_ok());
625630
}
626631

632+
#[test]
633+
fn test_asset_price_and_scale_must_be_paired() {
634+
let oracle_set = OracleSet {
635+
common_fields: CommonFields {
636+
account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(),
637+
transaction_type: TransactionType::OracleSet,
638+
..Default::default()
639+
},
640+
..Default::default()
641+
}
642+
.with_price_data_series(vec![PriceData {
643+
base_asset: "XRP".to_string(),
644+
quote_asset: "USD".to_string(),
645+
asset_price: Some("100".to_string()),
646+
scale: None,
647+
}]);
648+
649+
assert!(matches!(
650+
oracle_set.get_errors().unwrap_err(),
651+
XRPLModelException::InvalidValue { ref field, .. } if field == "price_data"
652+
));
653+
}
654+
627655
#[test]
628656
fn test_invalid_base_asset_rejected() {
629657
// A 4-character code is neither a valid ISO code nor a 40-char hex.
@@ -699,7 +727,7 @@ mod tests {
699727
transaction_type: TransactionType::OracleSet,
700728
..Default::default()
701729
},
702-
provider: Some(alloc::format!("{}x", "a".repeat(MAX_ORACLE_PROVIDER_BYTES)).into()),
730+
provider: Some("AA".repeat(MAX_ORACLE_PROVIDER_BYTES + 1).into()),
703731
price_data_series: vec![PriceData {
704732
base_asset: "XRP".to_string(),
705733
quote_asset: "USD".to_string(),
@@ -716,6 +744,53 @@ mod tests {
716744
));
717745
}
718746

747+
#[test]
748+
fn test_oracle_metadata_must_be_hex() {
749+
let oracle_set = OracleSet {
750+
common_fields: CommonFields {
751+
account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(),
752+
transaction_type: TransactionType::OracleSet,
753+
..Default::default()
754+
},
755+
provider: Some("chainlink".into()),
756+
price_data_series: vec![PriceData {
757+
base_asset: "XRP".to_string(),
758+
quote_asset: "USD".to_string(),
759+
asset_price: Some("100".to_string()),
760+
scale: Some(1),
761+
}],
762+
..Default::default()
763+
};
764+
765+
assert!(matches!(
766+
oracle_set.get_errors().unwrap_err(),
767+
XRPLModelException::InvalidValue { ref field, .. } if field == "provider"
768+
));
769+
}
770+
771+
#[test]
772+
fn test_asset_price_too_high_rejected() {
773+
let oracle_set = OracleSet {
774+
common_fields: CommonFields {
775+
account: "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW".into(),
776+
transaction_type: TransactionType::OracleSet,
777+
..Default::default()
778+
},
779+
..Default::default()
780+
}
781+
.with_price_data_series(vec![PriceData {
782+
base_asset: "XRP".to_string(),
783+
quote_asset: "USD".to_string(),
784+
asset_price: Some("8000000000000000".to_string()),
785+
scale: Some(1),
786+
}]);
787+
788+
assert!(matches!(
789+
oracle_set.get_errors().unwrap_err(),
790+
XRPLModelException::InvalidValue { ref field, .. } if field == "asset_price"
791+
));
792+
}
793+
719794
#[test]
720795
fn test_duplicate_price_data_pair_rejected() {
721796
let oracle_set = OracleSet {

tests/transactions/oracle_delete.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ fn test_oracle_delete_serde_roundtrip() {
5858
async fn test_oracle_delete_submit() {
5959
with_blockchain_lock(|| async {
6060
let wallet = generate_funded_wallet().await;
61-
let last_update_time = get_ledger_close_time().await as u32;
61+
// OracleSet LastUpdateTime is POSIX/Unix time. The ledger response uses
62+
// Ripple epoch seconds, so convert before submitting.
63+
let last_update_time = (get_ledger_close_time().await + 946_684_800) as u32;
6264
let oracle_document_id = 2;
6365

6466
let mut oracle_set = OracleSet::new(
@@ -72,14 +74,16 @@ async fn test_oracle_delete_submit() {
7274
None,
7375
None,
7476
oracle_document_id,
75-
Some("chainlink".into()),
77+
// Provider is a Blob, so it must be hex-encoded ("chainlink").
78+
Some("636861696E6C696E6B".into()),
7679
Some("68747470733A2F2F6578616D706C652E636F6D".into()),
7780
Some("63757272656E6379".into()),
7881
last_update_time,
7982
vec![PriceData {
8083
base_asset: "XRP".into(),
8184
quote_asset: "USD".into(),
82-
asset_price: Some("740".into()),
85+
// AssetPrice is a UInt64 hex string in XRPL binary JSON: 0x2E4 == 740.
86+
asset_price: Some("2E4".into()),
8387
scale: Some(1),
8488
}],
8589
);

tests/transactions/oracle_set.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@ fn test_oracle_set_serde_roundtrip() {
7878
async fn test_oracle_set_submit() {
7979
with_blockchain_lock(|| async {
8080
let wallet = generate_funded_wallet().await;
81-
let last_update_time = get_ledger_close_time().await as u32;
81+
// OracleSet LastUpdateTime is POSIX/Unix time. The ledger response uses
82+
// Ripple epoch seconds, so convert before submitting.
83+
let last_update_time = (get_ledger_close_time().await + 946_684_800) as u32;
8284

8385
let mut oracle_set = OracleSet::new(
8486
wallet.classic_address.clone().into(),
@@ -91,14 +93,16 @@ async fn test_oracle_set_submit() {
9193
None,
9294
None,
9395
1,
94-
Some("chainlink".into()),
96+
// Provider is a Blob, so it must be hex-encoded ("chainlink").
97+
Some("636861696E6C696E6B".into()),
9598
Some("68747470733A2F2F6578616D706C652E636F6D".into()),
9699
Some("63757272656E6379".into()),
97100
last_update_time,
98101
vec![PriceData {
99102
base_asset: "XRP".into(),
100103
quote_asset: "USD".into(),
101-
asset_price: Some("740".into()),
104+
// AssetPrice is a UInt64 hex string in XRPL binary JSON: 0x2E4 == 740.
105+
asset_price: Some("2E4".into()),
102106
scale: Some(1),
103107
}],
104108
);

0 commit comments

Comments
 (0)