Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions .github/workflows/integration_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ on:
name: Integration Test

env:
RIPPLED_DOCKER_IMAGE: rippleci/rippled:develop
# Pin to known-good digest; rippleci/rippled:develop broke after 2026-04-01
RIPPLED_DOCKER_IMAGE: rippleci/rippled:develop@sha256:328175bf14b7b83db9e5e6b50c7458bf828b02b2855453efc038233094aa8d85

jobs:
integration_test:
Expand Down Expand Up @@ -41,10 +42,22 @@ jobs:

- name: Wait for rippled to be healthy
run: |
until docker inspect --format='{{.State.Health.Status}}' rippled-service | grep -q healthy; do
echo "Waiting for rippled to be ready..."
for i in $(seq 1 30); do
if ! docker ps -q -f name=rippled-service | grep -q .; then
echo "Container exited unexpectedly"
docker logs rippled-service 2>&1 || true
exit 1
fi
STATUS=$(docker inspect --format='{{.State.Health.Status}}' rippled-service 2>/dev/null || echo "unknown")
echo "Attempt $i/30: $STATUS"
if [ "$STATUS" = "healthy" ]; then
exit 0
fi
sleep 2
done
echo "Timed out waiting for rippled"
docker logs rippled-service 2>&1 || true
exit 1

- uses: dtolnay/rust-toolchain@stable

Expand Down
4 changes: 4 additions & 0 deletions src/models/ledger/objects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -66,6 +68,7 @@ pub enum LedgerEntryType {
NFTokenOffer = 0x0037,
NFTokenPage = 0x0050,
Offer = 0x006F,
Oracle = 0x0080,
PayChannel = 0x0078,
RippleState = 0x0072,
SignerList = 0x0053,
Expand All @@ -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>),
Expand Down
211 changes: 211 additions & 0 deletions src/models/ledger/objects/oracle.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
use crate::models::ledger::objects::LedgerEntryType;
use crate::models::transactions::PriceData;
use crate::models::{FlagCollection, Model, NoFlags};
use alloc::borrow::Cow;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;

use super::{CommonFields, LedgerObject};

/// The Oracle ledger entry holds data associated with a single price oracle object.
///
/// See Oracle:
/// `<https://xrpl.org/docs/references/protocol/ledger-data/ledger-entry-types/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:
/// `<https://xrpl.org/ledger-entry-common-fields.html>`
#[serde(flatten)]
pub common_fields: CommonFields<'a, NoFlags>,
/// The XRPL account with update and delete privileges for the oracle.
pub owner: Cow<'a, str>,
/// An arbitrary value that identifies an oracle provider.
pub provider: Cow<'a, str>,
/// Describes the type of asset, such as "currency", "commodity", or "NFT".
pub asset_class: Option<Cow<'a, str>>,
/// An array of up to 10 PriceData objects, representing the price information.
pub price_data_series: Option<Vec<PriceData>>,
/// 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<Cow<'a, str>>,
/// A hint indicating which page of the owner directory links to this entry.
pub owner_node: Option<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,
}

impl Model for Oracle<'_> {}

impl<'a> LedgerObject<NoFlags> for Oracle<'a> {
fn get_ledger_entry_type(&self) -> LedgerEntryType {
self.common_fields.get_ledger_entry_type()
}
}

impl<'a> Oracle<'a> {
pub fn new(
index: Option<Cow<'a, str>>,
ledger_index: Option<Cow<'a, str>>,
owner: Cow<'a, str>,
provider: Cow<'a, str>,
asset_class: Option<Cow<'a, str>>,
price_data_series: Option<Vec<PriceData>>,
last_update_time: u32,
uri: Option<Cow<'a, str>>,
owner_node: Option<Cow<'a, str>>,
previous_txn_id: Cow<'a, str>,
previous_txn_lgr_seq: u32,
) -> Self {
Self {
common_fields: CommonFields {
flags: FlagCollection::default(),
ledger_entry_type: LedgerEntryType::Oracle,
index,
ledger_index,
},
owner,
provider,
asset_class,
price_data_series,
last_update_time,
uri,
owner_node,
previous_txn_id,
previous_txn_lgr_seq,
}
}
}

#[cfg(test)]
mod test_serde {
use super::*;
use crate::models::transactions::PriceData;
use alloc::borrow::Cow;
use alloc::string::ToString;
use alloc::vec;

#[test]
fn test_serialize() {
let oracle = Oracle::new(
Some(Cow::from("ForTest")),
None,
Cow::from("rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"),
Cow::from("chainlink"),
Some(Cow::from("63757272656E6379")),
Some(vec![PriceData {
base_asset: Some("XRP".to_string()),
quote_asset: Some("USD".to_string()),
asset_price: Some("740".to_string()),
scale: Some(1),
}]),
743609014,
Some(Cow::from("https://example.com/oracle1")),
Some(Cow::from("0")),
Cow::from("ABC123DEF456"),
12345678,
);

let serialized = serde_json::to_string(&oracle).unwrap();
let deserialized: Oracle = serde_json::from_str(&serialized).unwrap();
assert_eq!(oracle, deserialized);
}

#[test]
fn test_new_minimal() {
let oracle = Oracle::new(
None,
None,
Cow::from("rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"),
Cow::from("provider1"),
None,
None,
743609014,
None,
None,
Cow::from("ABC123"),
100,
);

assert_eq!(oracle.owner, "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW");
assert_eq!(oracle.provider, "provider1");
assert!(oracle.asset_class.is_none());
assert!(oracle.price_data_series.is_none());
assert_eq!(oracle.last_update_time, 743609014);
assert!(oracle.uri.is_none());
assert!(oracle.owner_node.is_none());
assert_eq!(oracle.previous_txn_id, "ABC123");
assert_eq!(oracle.previous_txn_lgr_seq, 100);
}

#[test]
fn test_ledger_entry_type() {
let oracle = Oracle::new(
None,
None,
Cow::from("rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"),
Cow::from("provider1"),
None,
None,
0,
None,
None,
Cow::from("ABC123"),
0,
);

assert_eq!(oracle.get_ledger_entry_type(), LedgerEntryType::Oracle);
}

#[test]
fn test_with_multiple_price_data() {
let oracle = Oracle::new(
Some(Cow::from("TestIndex")),
None,
Cow::from("rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW"),
Cow::from("chainlink"),
Some(Cow::from("63757272656E6379")),
Some(vec![
PriceData {
base_asset: Some("XRP".to_string()),
quote_asset: Some("USD".to_string()),
asset_price: Some("740".to_string()),
scale: Some(1),
},
PriceData {
base_asset: Some("BTC".to_string()),
quote_asset: Some("USD".to_string()),
asset_price: Some("2600000".to_string()),
scale: Some(2),
},
PriceData {
base_asset: Some("ETH".to_string()),
quote_asset: Some("USD".to_string()),
asset_price: Some("160000".to_string()),
scale: Some(2),
},
]),
743609014,
Some(Cow::from("https://example.com")),
Some(Cow::from("0")),
Cow::from("DEF789"),
99999,
);

let series = oracle.price_data_series.as_ref().unwrap();
assert_eq!(series.len(), 3);
assert_eq!(series[0].base_asset.as_deref(), Some("XRP"));
assert_eq!(series[1].base_asset.as_deref(), Some("BTC"));
assert_eq!(series[2].base_asset.as_deref(), Some("ETH"));
}
}
74 changes: 74 additions & 0 deletions src/models/transactions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -87,6 +89,8 @@ pub enum TransactionType {
NFTokenMint,
OfferCancel,
OfferCreate,
OracleDelete,
OracleSet,
#[default]
Payment,
PaymentChannelClaim,
Expand Down Expand Up @@ -572,6 +576,76 @@ pub struct Signer {
}
}

serde_with_tag! {
/// Represents a single price data entry in an Oracle's PriceDataSeries.
///
/// See OracleSet:
/// `<https://xrpl.org/docs/references/protocol/transactions/types/oracleset>`
#[derive(Debug, PartialEq, Eq, Clone, Default)]
pub struct PriceData {
pub base_asset: Option<String>,
pub quote_asset: Option<String>,
pub asset_price: Option<String>,
pub scale: Option<u8>,
}
}

/// Maximum allowed value for the `scale` field of a `PriceData` entry.
///
/// Per XLS-47, rippled enforces `0 <= scale <= 10`.
pub const MAX_PRICE_DATA_SCALE: u8 = 10;

impl crate::models::Model for PriceData {
fn get_errors(&self) -> crate::models::XRPLModelResult<()> {
if let Some(scale) = self.scale {
if scale > MAX_PRICE_DATA_SCALE {
return Err(crate::models::XRPLModelException::ValueTooHigh {
field: "scale".into(),
max: MAX_PRICE_DATA_SCALE as u32,
found: scale as u32,
});
}
}
if let Some(ref base_asset) = self.base_asset {
validate_oracle_currency("base_asset", base_asset)?;
}
if let Some(ref quote_asset) = self.quote_asset {
validate_oracle_currency("quote_asset", quote_asset)?;
}
Ok(())
}
}

/// Validate a currency code used in a `PriceData` entry.
///
/// Accepts either a 3-character ISO-style code (uppercase letters and digits)
/// or a 40-character hex code. The reserved symbol `"XRP"` is rejected
/// because an XRP oracle price is not meaningful (XRP is the ledger's native
/// asset and is quoted directly, not via an IOU currency code).
fn validate_oracle_currency(
field: &'static str,
value: &str,
) -> crate::models::XRPLModelResult<()> {
if value == "XRP" {
return Err(crate::models::XRPLModelException::InvalidValue {
field: field.into(),
expected:
"a 3-character ISO currency code (excluding \"XRP\") or 40-character hex code"
.into(),
found: value.into(),
});
}
if crate::utils::is_iso_code(value) || crate::utils::is_iso_hex(value) {
return Ok(());
}
Err(crate::models::XRPLModelException::InvalidValue {
field: field.into(),
expected: "a 3-character ISO currency code (excluding \"XRP\") or 40-character hex code"
.into(),
found: value.into(),
})
}

/// Standard functions for transactions.
pub trait Transaction<'a, T>
where
Expand Down
Loading
Loading