From bdb113d08f204b1d03267d6e25e47678b543f961 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Wed, 1 Apr 2026 22:45:00 +0000 Subject: [PATCH 1/7] feat: add XLS-65 SingleAssetVault transaction types Add six new transaction types for the XLS-65 single-asset vault amendment: VaultCreate, VaultDelete, VaultDeposit, VaultWithdraw, VaultClawback, and VaultSet. Each follows existing transaction patterns with serde support, builder methods, and unit tests. --- src/models/transactions/mod.rs | 12 + src/models/transactions/vault_clawback.rs | 346 +++++++++++++++++ src/models/transactions/vault_create.rs | 438 ++++++++++++++++++++++ src/models/transactions/vault_delete.rs | 298 +++++++++++++++ src/models/transactions/vault_deposit.rs | 320 ++++++++++++++++ src/models/transactions/vault_set.rs | 360 ++++++++++++++++++ src/models/transactions/vault_withdraw.rs | 323 ++++++++++++++++ 7 files changed, 2097 insertions(+) create mode 100644 src/models/transactions/vault_clawback.rs create mode 100644 src/models/transactions/vault_create.rs create mode 100644 src/models/transactions/vault_delete.rs create mode 100644 src/models/transactions/vault_deposit.rs create mode 100644 src/models/transactions/vault_set.rs create mode 100644 src/models/transactions/vault_withdraw.rs diff --git a/src/models/transactions/mod.rs b/src/models/transactions/mod.rs index 167d56c8..0c1f5f14 100644 --- a/src/models/transactions/mod.rs +++ b/src/models/transactions/mod.rs @@ -31,6 +31,12 @@ pub mod set_regular_key; pub mod signer_list_set; pub mod ticket_create; pub mod trust_set; +pub mod vault_clawback; +pub mod vault_create; +pub mod vault_delete; +pub mod vault_deposit; +pub mod vault_set; +pub mod vault_withdraw; pub mod xchain_account_create_commit; pub mod xchain_add_account_create_attestation; pub mod xchain_add_claim_attestation; @@ -96,6 +102,12 @@ pub enum TransactionType { SignerListSet, TicketCreate, TrustSet, + VaultClawback, + VaultCreate, + VaultDelete, + VaultDeposit, + VaultSet, + VaultWithdraw, XChainAccountCreateCommit, XChainAddAccountCreateAttestation, XChainAddClaimAttestation, diff --git a/src/models/transactions/vault_clawback.rs b/src/models/transactions/vault_clawback.rs new file mode 100644 index 00000000..ec7b566d --- /dev/null +++ b/src/models/transactions/vault_clawback.rs @@ -0,0 +1,346 @@ +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::{Amount, FlagCollection, Model, NoFlags, ValidateCurrencies, XRPLModelResult}; + +use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType}; + +/// Claw back assets from a vault holder on the XRP Ledger (XLS-65). +/// +/// The issuer of the vault's asset can claw back deposited assets from a +/// specific holder, burning the holder's share tokens in the process. +/// +/// See VaultClawback transaction: +/// `` +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct VaultClawback<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The ID of the vault to claw back from (256-bit hex string). + #[serde(rename = "VaultID")] + pub vault_id: Cow<'a, str>, + /// The account address of the holder whose assets are being clawed back. + pub holder: Cow<'a, str>, + /// The amount of the asset to claw back from the holder. + pub amount: Amount<'a>, +} + +impl Model for VaultClawback<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + self.validate_currencies() + } +} + +impl<'a> Transaction<'a, NoFlags> for VaultClawback<'a> { + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + &self.common_fields + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for VaultClawback<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> VaultClawback<'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, + vault_id: Cow<'a, str>, + holder: Cow<'a, str>, + amount: Amount<'a>, + ) -> VaultClawback<'a> { + VaultClawback { + common_fields: CommonFields::new( + account, + TransactionType::VaultClawback, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + vault_id, + holder, + amount, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{IssuedCurrencyAmount, XRPAmount}; + + const VAULT_ID: &str = "A0000000000000000000000000000000000000000000000000000000DEADBEEF"; + + #[test] + fn test_serde() { + let vault_clawback = VaultClawback { + common_fields: CommonFields { + account: "rIssuer123".into(), + transaction_type: TransactionType::VaultClawback, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + holder: "rHolder456".into(), + amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( + "USD".into(), + "rIssuer123".into(), + "500".into(), + )), + }; + + let json_str = r#"{"Account":"rIssuer123","TransactionType":"VaultClawback","Flags":0,"SigningPubKey":"","VaultID":"A0000000000000000000000000000000000000000000000000000000DEADBEEF","Holder":"rHolder456","Amount":{"currency":"USD","issuer":"rIssuer123","value":"500"}}"#; + + // Serialize + let serialized = serde_json::to_string(&vault_clawback).unwrap(); + assert_eq!( + serde_json::to_value(&serialized).unwrap(), + serde_json::to_value(json_str).unwrap() + ); + + // Deserialize + let deserialized: VaultClawback = serde_json::from_str(json_str).unwrap(); + assert_eq!(vault_clawback, deserialized); + } + + #[test] + fn test_serde_xrp_amount() { + let vault_clawback = VaultClawback { + common_fields: CommonFields { + account: "rIssuerXRP789".into(), + transaction_type: TransactionType::VaultClawback, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + holder: "rHolderXRP012".into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + }; + + let serialized = serde_json::to_string(&vault_clawback).unwrap(); + let deserialized: VaultClawback = serde_json::from_str(&serialized).unwrap(); + assert_eq!(vault_clawback, deserialized); + } + + #[test] + fn test_builder_pattern() { + let vault_clawback = VaultClawback { + common_fields: CommonFields { + account: "rIssuer123".into(), + transaction_type: TransactionType::VaultClawback, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + holder: "rHolder456".into(), + amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( + "USD".into(), + "rIssuer123".into(), + "500".into(), + )), + } + .with_fee("12".into()) + .with_sequence(100) + .with_last_ledger_sequence(7108682) + .with_source_tag(12345) + .with_memo(Memo { + memo_data: Some("clawback from holder".into()), + memo_format: None, + memo_type: Some("text".into()), + }); + + assert_eq!(vault_clawback.vault_id, VAULT_ID); + assert_eq!(vault_clawback.holder, "rHolder456"); + assert_eq!(vault_clawback.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(vault_clawback.common_fields.sequence, Some(100)); + assert_eq!( + vault_clawback.common_fields.last_ledger_sequence, + Some(7108682) + ); + assert_eq!(vault_clawback.common_fields.source_tag, Some(12345)); + assert_eq!( + vault_clawback.common_fields.memos.as_ref().unwrap().len(), + 1 + ); + } + + #[test] + fn test_default() { + let vault_clawback = VaultClawback { + common_fields: CommonFields { + account: "rIssuer789".into(), + transaction_type: TransactionType::VaultClawback, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + holder: "rHolder012".into(), + amount: Amount::XRPAmount(XRPAmount::from("100000")), + }; + + assert_eq!(vault_clawback.common_fields.account, "rIssuer789"); + assert_eq!( + vault_clawback.common_fields.transaction_type, + TransactionType::VaultClawback + ); + assert_eq!(vault_clawback.vault_id, VAULT_ID); + assert_eq!(vault_clawback.holder, "rHolder012"); + assert!(vault_clawback.common_fields.fee.is_none()); + assert!(vault_clawback.common_fields.sequence.is_none()); + } + + #[test] + fn test_ticket_sequence() { + let ticket_clawback = VaultClawback { + common_fields: CommonFields { + account: "rTicketIssuer111".into(), + transaction_type: TransactionType::VaultClawback, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + holder: "rTicketHolder222".into(), + amount: Amount::XRPAmount(XRPAmount::from("2000000")), + } + .with_ticket_sequence(54321) + .with_fee("12".into()); + + assert_eq!(ticket_clawback.common_fields.ticket_sequence, Some(54321)); + assert!(ticket_clawback.common_fields.sequence.is_none()); + } + + #[test] + fn test_multiple_memos() { + let multi_memo_clawback = VaultClawback { + common_fields: CommonFields { + account: "rMultiMemoIssuer333".into(), + transaction_type: TransactionType::VaultClawback, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + holder: "rMultiMemoHolder444".into(), + amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( + "EUR".into(), + "rMultiMemoIssuer333".into(), + "1000".into(), + )), + } + .with_memo(Memo { + memo_data: Some("compliance action".into()), + memo_format: None, + memo_type: Some("text".into()), + }) + .with_memo(Memo { + memo_data: Some("regulatory requirement".into()), + memo_format: None, + memo_type: Some("text".into()), + }) + .with_fee("18".into()) + .with_sequence(400); + + assert_eq!( + multi_memo_clawback + .common_fields + .memos + .as_ref() + .unwrap() + .len(), + 2 + ); + assert_eq!(multi_memo_clawback.common_fields.sequence, Some(400)); + } + + #[test] + fn test_new_constructor() { + let vault_clawback = VaultClawback::new( + "rNewIssuer555".into(), + None, + Some("12".into()), + Some(7108682), + None, + Some(100), + None, + None, + None, + VAULT_ID.into(), + "rNewHolder666".into(), + Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( + "USD".into(), + "rNewIssuer555".into(), + "750".into(), + )), + ); + + assert_eq!(vault_clawback.common_fields.account, "rNewIssuer555"); + assert_eq!( + vault_clawback.common_fields.transaction_type, + TransactionType::VaultClawback + ); + assert_eq!(vault_clawback.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(vault_clawback.vault_id, VAULT_ID); + assert_eq!(vault_clawback.holder, "rNewHolder666"); + } + + #[test] + fn test_validate() { + let vault_clawback = VaultClawback { + common_fields: CommonFields { + account: "rValidateIssuer777".into(), + transaction_type: TransactionType::VaultClawback, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + holder: "rValidateHolder888".into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + } + .with_fee("12".into()) + .with_sequence(300); + + assert!(vault_clawback.validate().is_ok()); + } +} diff --git a/src/models/transactions/vault_create.rs b/src/models/transactions/vault_create.rs new file mode 100644 index 00000000..ed7534b0 --- /dev/null +++ b/src/models/transactions/vault_create.rs @@ -0,0 +1,438 @@ +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::{ + Currency, FlagCollection, Model, NoFlags, ValidateCurrencies, XRPLModelResult, +}; + +use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType}; + +/// Create a new single-asset vault on the XRP Ledger (XLS-65). +/// +/// A vault holds a single asset type and issues share tokens (MPTokens) +/// to depositors proportional to their ownership of the vault's assets. +/// +/// See VaultCreate transaction: +/// `` +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct VaultCreate<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The asset that this vault will hold. + pub asset: Currency<'a>, + /// Arbitrary hex-encoded data associated with the vault. + pub data: Option>, + /// The maximum amount of assets the vault can hold, as a string-encoded integer. + pub assets_maximum: Option>, + /// Metadata for the MPToken issued by the vault. + #[serde(rename = "MPTokenMetadata")] + pub mptoken_metadata: Option>, + /// The domain ID associated with the vault. + #[serde(rename = "DomainID")] + pub domain_id: Option>, + /// The withdrawal policy for the vault. + /// 0 = unrestricted, 1 = stranded (withdrawals require approval). + pub withdrawal_policy: Option, +} + +impl Model for VaultCreate<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + self.validate_currencies() + } +} + +impl<'a> Transaction<'a, NoFlags> for VaultCreate<'a> { + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + &self.common_fields + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for VaultCreate<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> VaultCreate<'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, + asset: Currency<'a>, + data: Option>, + assets_maximum: Option>, + mptoken_metadata: Option>, + domain_id: Option>, + withdrawal_policy: Option, + ) -> VaultCreate<'a> { + VaultCreate { + common_fields: CommonFields::new( + account, + TransactionType::VaultCreate, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + asset, + data, + assets_maximum, + mptoken_metadata, + domain_id, + withdrawal_policy, + } + } + + /// Set the data field. + pub fn with_data(mut self, data: Cow<'a, str>) -> Self { + self.data = Some(data); + self + } + + /// Set the assets maximum field. + pub fn with_assets_maximum(mut self, assets_maximum: Cow<'a, str>) -> Self { + self.assets_maximum = Some(assets_maximum); + self + } + + /// Set the MPToken metadata field. + pub fn with_mptoken_metadata(mut self, mptoken_metadata: Cow<'a, str>) -> Self { + self.mptoken_metadata = Some(mptoken_metadata); + self + } + + /// Set the domain ID field. + pub fn with_domain_id(mut self, domain_id: Cow<'a, str>) -> Self { + self.domain_id = Some(domain_id); + self + } + + /// Set the withdrawal policy field. + pub fn with_withdrawal_policy(mut self, withdrawal_policy: u8) -> Self { + self.withdrawal_policy = Some(withdrawal_policy); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::currency::{IssuedCurrency, XRP}; + + #[test] + fn test_serde() { + let vault_create = VaultCreate { + common_fields: CommonFields { + account: "rVaultCreator123".into(), + transaction_type: TransactionType::VaultCreate, + signing_pub_key: Some("".into()), + ..Default::default() + }, + asset: Currency::IssuedCurrency(IssuedCurrency::new("USD".into(), "rIssuer456".into())), + data: None, + assets_maximum: None, + mptoken_metadata: None, + domain_id: None, + withdrawal_policy: None, + }; + + let json_str = r#"{"Account":"rVaultCreator123","TransactionType":"VaultCreate","Flags":0,"SigningPubKey":"","Asset":{"currency":"USD","issuer":"rIssuer456"}}"#; + + // Serialize + let serialized = serde_json::to_string(&vault_create).unwrap(); + assert_eq!( + serde_json::to_value(&serialized).unwrap(), + serde_json::to_value(json_str).unwrap() + ); + + // Deserialize + let deserialized: VaultCreate = serde_json::from_str(json_str).unwrap(); + assert_eq!(vault_create, deserialized); + } + + #[test] + fn test_serde_with_all_fields() { + let vault_create = VaultCreate { + common_fields: CommonFields { + account: "rVaultCreator789".into(), + transaction_type: TransactionType::VaultCreate, + signing_pub_key: Some("".into()), + ..Default::default() + }, + asset: Currency::XRP(XRP::new()), + data: Some("48656C6C6F".into()), + assets_maximum: Some("1000000000".into()), + mptoken_metadata: Some("ABCDEF".into()), + domain_id: Some( + "D0000000000000000000000000000000000000000000000000000000DEADBEEF".into(), + ), + withdrawal_policy: Some(1), + }; + + let serialized = serde_json::to_string(&vault_create).unwrap(); + let deserialized: VaultCreate = serde_json::from_str(&serialized).unwrap(); + assert_eq!(vault_create, deserialized); + } + + #[test] + fn test_builder_pattern() { + let vault_create = VaultCreate { + common_fields: CommonFields { + account: "rVaultCreator123".into(), + transaction_type: TransactionType::VaultCreate, + ..Default::default() + }, + asset: Currency::IssuedCurrency(IssuedCurrency::new("USD".into(), "rIssuer456".into())), + ..Default::default() + } + .with_fee("12".into()) + .with_sequence(100) + .with_last_ledger_sequence(7108682) + .with_source_tag(12345) + .with_data("48656C6C6F".into()) + .with_assets_maximum("1000000000".into()) + .with_mptoken_metadata("ABCDEF".into()) + .with_domain_id("D0000000000000000000000000000000000000000000000000000000DEADBEEF".into()) + .with_withdrawal_policy(1) + .with_memo(Memo { + memo_data: Some("creating vault".into()), + memo_format: None, + memo_type: Some("text".into()), + }); + + assert_eq!(vault_create.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(vault_create.common_fields.sequence, Some(100)); + assert_eq!( + vault_create.common_fields.last_ledger_sequence, + Some(7108682) + ); + assert_eq!(vault_create.common_fields.source_tag, Some(12345)); + assert_eq!(vault_create.data, Some("48656C6C6F".into())); + assert_eq!(vault_create.assets_maximum, Some("1000000000".into())); + assert_eq!(vault_create.mptoken_metadata, Some("ABCDEF".into())); + assert_eq!(vault_create.withdrawal_policy, Some(1)); + assert_eq!(vault_create.common_fields.memos.as_ref().unwrap().len(), 1); + } + + #[test] + fn test_default() { + let vault_create = VaultCreate { + common_fields: CommonFields { + account: "rVaultCreator123".into(), + transaction_type: TransactionType::VaultCreate, + ..Default::default() + }, + asset: Currency::IssuedCurrency(IssuedCurrency::new("USD".into(), "rIssuer456".into())), + ..Default::default() + }; + + assert_eq!(vault_create.common_fields.account, "rVaultCreator123"); + assert_eq!( + vault_create.common_fields.transaction_type, + TransactionType::VaultCreate + ); + assert!(vault_create.data.is_none()); + assert!(vault_create.assets_maximum.is_none()); + assert!(vault_create.mptoken_metadata.is_none()); + assert!(vault_create.domain_id.is_none()); + assert!(vault_create.withdrawal_policy.is_none()); + assert!(vault_create.common_fields.fee.is_none()); + assert!(vault_create.common_fields.sequence.is_none()); + } + + #[test] + fn test_xrp_vault() { + let xrp_vault = VaultCreate { + common_fields: CommonFields { + account: "rXRPVaultCreator789".into(), + transaction_type: TransactionType::VaultCreate, + ..Default::default() + }, + asset: Currency::XRP(XRP::new()), + ..Default::default() + } + .with_fee("12".into()) + .with_sequence(100) + .with_assets_maximum("50000000000".into()); + + assert!(matches!(xrp_vault.asset, Currency::XRP(_))); + assert_eq!(xrp_vault.assets_maximum, Some("50000000000".into())); + assert_eq!(xrp_vault.common_fields.sequence, Some(100)); + assert!(xrp_vault.validate().is_ok()); + } + + #[test] + fn test_issued_currency_vault() { + let token_vault = VaultCreate { + common_fields: CommonFields { + account: "rTokenVaultCreator111".into(), + transaction_type: TransactionType::VaultCreate, + ..Default::default() + }, + asset: Currency::IssuedCurrency(IssuedCurrency::new( + "USD".into(), + "rUSDIssuer222".into(), + )), + ..Default::default() + } + .with_fee("15".into()) + .with_sequence(200) + .with_withdrawal_policy(0); + + assert!(matches!(token_vault.asset, Currency::IssuedCurrency(_))); + assert_eq!(token_vault.withdrawal_policy, Some(0)); + assert!(token_vault.validate().is_ok()); + } + + #[test] + fn test_ticket_sequence() { + let ticket_vault = VaultCreate { + common_fields: CommonFields { + account: "rTicketVault333".into(), + transaction_type: TransactionType::VaultCreate, + ..Default::default() + }, + asset: Currency::XRP(XRP::new()), + ..Default::default() + } + .with_ticket_sequence(12345) + .with_fee("12".into()); + + assert_eq!(ticket_vault.common_fields.ticket_sequence, Some(12345)); + assert!(ticket_vault.common_fields.sequence.is_none()); + } + + #[test] + fn test_multiple_memos() { + let multi_memo_vault = VaultCreate { + common_fields: CommonFields { + account: "rMultiMemoVault444".into(), + transaction_type: TransactionType::VaultCreate, + ..Default::default() + }, + asset: Currency::IssuedCurrency(IssuedCurrency::new( + "EUR".into(), + "rEURIssuer555".into(), + )), + ..Default::default() + } + .with_memo(Memo { + memo_data: Some("first memo".into()), + memo_format: None, + memo_type: Some("text".into()), + }) + .with_memo(Memo { + memo_data: Some("second memo".into()), + memo_format: None, + memo_type: Some("text".into()), + }) + .with_fee("18".into()) + .with_sequence(400); + + assert_eq!( + multi_memo_vault.common_fields.memos.as_ref().unwrap().len(), + 2 + ); + assert_eq!(multi_memo_vault.common_fields.sequence, Some(400)); + } + + #[test] + fn test_new_constructor() { + let vault = VaultCreate::new( + "rNewVaultAccount".into(), + None, + Some("12".into()), + Some(7108682), + None, + Some(100), + None, + None, + None, + Currency::IssuedCurrency(IssuedCurrency::new("USD".into(), "rIssuer789".into())), + Some("48656C6C6F".into()), + Some("1000000000".into()), + Some("ABCDEF".into()), + Some("D0000000000000000000000000000000000000000000000000000000DEADBEEF".into()), + Some(1), + ); + + assert_eq!(vault.common_fields.account, "rNewVaultAccount"); + assert_eq!( + vault.common_fields.transaction_type, + TransactionType::VaultCreate + ); + assert_eq!(vault.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(vault.common_fields.last_ledger_sequence, Some(7108682)); + assert_eq!(vault.common_fields.sequence, Some(100)); + assert_eq!(vault.data, Some("48656C6C6F".into())); + assert_eq!(vault.assets_maximum, Some("1000000000".into())); + assert_eq!(vault.mptoken_metadata, Some("ABCDEF".into())); + assert_eq!(vault.withdrawal_policy, Some(1)); + } + + #[test] + fn test_stranded_withdrawal_policy() { + let stranded_vault = VaultCreate { + common_fields: CommonFields { + account: "rStrandedVault666".into(), + transaction_type: TransactionType::VaultCreate, + ..Default::default() + }, + asset: Currency::IssuedCurrency(IssuedCurrency::new( + "BTC".into(), + "rBTCIssuer777".into(), + )), + withdrawal_policy: Some(1), + ..Default::default() + } + .with_fee("12".into()) + .with_sequence(500); + + assert_eq!(stranded_vault.withdrawal_policy, Some(1)); + assert!(stranded_vault.validate().is_ok()); + } +} diff --git a/src/models/transactions/vault_delete.rs b/src/models/transactions/vault_delete.rs new file mode 100644 index 00000000..6a07f3d5 --- /dev/null +++ b/src/models/transactions/vault_delete.rs @@ -0,0 +1,298 @@ +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::{FlagCollection, Model, NoFlags, XRPLModelResult}; + +use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType}; + +/// Delete a vault from the XRP Ledger (XLS-65). +/// +/// The vault must be empty (no remaining assets) before it can be deleted. +/// Only the vault owner can submit this transaction. +/// +/// See VaultDelete transaction: +/// `` +#[skip_serializing_none] +#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct VaultDelete<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The ID of the vault to delete (256-bit hex string). + #[serde(rename = "VaultID")] + pub vault_id: Cow<'a, str>, +} + +impl Model for VaultDelete<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + Ok(()) + } +} + +impl<'a> Transaction<'a, NoFlags> for VaultDelete<'a> { + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + &self.common_fields + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for VaultDelete<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> VaultDelete<'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, + vault_id: Cow<'a, str>, + ) -> VaultDelete<'a> { + VaultDelete { + common_fields: CommonFields::new( + account, + TransactionType::VaultDelete, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + vault_id, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VAULT_ID: &str = "A0000000000000000000000000000000000000000000000000000000DEADBEEF"; + + #[test] + fn test_serde() { + let vault_delete = VaultDelete { + common_fields: CommonFields { + account: "rVaultOwner123".into(), + transaction_type: TransactionType::VaultDelete, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + }; + + let json_str = r#"{"Account":"rVaultOwner123","TransactionType":"VaultDelete","Flags":0,"SigningPubKey":"","VaultID":"A0000000000000000000000000000000000000000000000000000000DEADBEEF"}"#; + + // Serialize + let serialized = serde_json::to_string(&vault_delete).unwrap(); + assert_eq!( + serde_json::to_value(&serialized).unwrap(), + serde_json::to_value(json_str).unwrap() + ); + + // Deserialize + let deserialized: VaultDelete = serde_json::from_str(json_str).unwrap(); + assert_eq!(vault_delete, deserialized); + } + + #[test] + fn test_builder_pattern() { + let vault_delete = VaultDelete { + common_fields: CommonFields { + account: "rVaultOwner123".into(), + transaction_type: TransactionType::VaultDelete, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + } + .with_fee("12".into()) + .with_sequence(100) + .with_last_ledger_sequence(7108682) + .with_source_tag(12345) + .with_memo(Memo { + memo_data: Some("deleting vault".into()), + memo_format: None, + memo_type: Some("text".into()), + }); + + assert_eq!(vault_delete.vault_id, VAULT_ID); + assert_eq!(vault_delete.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(vault_delete.common_fields.sequence, Some(100)); + assert_eq!( + vault_delete.common_fields.last_ledger_sequence, + Some(7108682) + ); + assert_eq!(vault_delete.common_fields.source_tag, Some(12345)); + assert_eq!(vault_delete.common_fields.memos.as_ref().unwrap().len(), 1); + } + + #[test] + fn test_default() { + let vault_delete = VaultDelete { + common_fields: CommonFields { + account: "rVaultOwner456".into(), + transaction_type: TransactionType::VaultDelete, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + }; + + assert_eq!(vault_delete.common_fields.account, "rVaultOwner456"); + assert_eq!( + vault_delete.common_fields.transaction_type, + TransactionType::VaultDelete + ); + assert_eq!(vault_delete.vault_id, VAULT_ID); + assert!(vault_delete.common_fields.fee.is_none()); + assert!(vault_delete.common_fields.sequence.is_none()); + } + + #[test] + fn test_ticket_sequence() { + let ticket_delete = VaultDelete { + common_fields: CommonFields { + account: "rTicketVaultDel789".into(), + transaction_type: TransactionType::VaultDelete, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + } + .with_ticket_sequence(54321) + .with_fee("12".into()); + + assert_eq!(ticket_delete.common_fields.ticket_sequence, Some(54321)); + assert!(ticket_delete.common_fields.sequence.is_none()); + } + + #[test] + fn test_multiple_memos() { + let multi_memo_delete = VaultDelete { + common_fields: CommonFields { + account: "rMultiMemoVaultDel111".into(), + transaction_type: TransactionType::VaultDelete, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + } + .with_memo(Memo { + memo_data: Some("first memo".into()), + memo_format: None, + memo_type: Some("text".into()), + }) + .with_memo(Memo { + memo_data: Some("second memo".into()), + memo_format: None, + memo_type: Some("text".into()), + }) + .with_fee("18".into()) + .with_sequence(400); + + assert_eq!( + multi_memo_delete + .common_fields + .memos + .as_ref() + .unwrap() + .len(), + 2 + ); + assert_eq!(multi_memo_delete.common_fields.sequence, Some(400)); + } + + #[test] + fn test_new_constructor() { + let vault_delete = VaultDelete::new( + "rNewDeleter222".into(), + None, + Some("12".into()), + Some(7108682), + None, + Some(100), + None, + None, + None, + VAULT_ID.into(), + ); + + assert_eq!(vault_delete.common_fields.account, "rNewDeleter222"); + assert_eq!( + vault_delete.common_fields.transaction_type, + TransactionType::VaultDelete + ); + assert_eq!(vault_delete.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!( + vault_delete.common_fields.last_ledger_sequence, + Some(7108682) + ); + assert_eq!(vault_delete.common_fields.sequence, Some(100)); + assert_eq!(vault_delete.vault_id, VAULT_ID); + } + + #[test] + fn test_validate() { + let vault_delete = VaultDelete { + common_fields: CommonFields { + account: "rValidateVaultDel333".into(), + transaction_type: TransactionType::VaultDelete, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + } + .with_fee("12".into()) + .with_sequence(300); + + assert!(vault_delete.validate().is_ok()); + } + + #[test] + fn test_account_txn_id() { + let vault_delete = VaultDelete { + common_fields: CommonFields { + account: "rVaultDelTxnId444".into(), + transaction_type: TransactionType::VaultDelete, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + } + .with_account_txn_id("F1E2D3C4B5A69788".into()) + .with_fee("12".into()) + .with_sequence(500); + + assert_eq!( + vault_delete.common_fields.account_txn_id, + Some("F1E2D3C4B5A69788".into()) + ); + } +} diff --git a/src/models/transactions/vault_deposit.rs b/src/models/transactions/vault_deposit.rs new file mode 100644 index 00000000..eda0e494 --- /dev/null +++ b/src/models/transactions/vault_deposit.rs @@ -0,0 +1,320 @@ +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::{Amount, FlagCollection, Model, NoFlags, ValidateCurrencies, XRPLModelResult}; + +use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType}; + +/// Deposit assets into a vault on the XRP Ledger (XLS-65). +/// +/// The depositor receives share tokens (MPTokens) proportional to their +/// deposit relative to the vault's total assets. +/// +/// See VaultDeposit transaction: +/// `` +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct VaultDeposit<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The ID of the vault to deposit into (256-bit hex string). + #[serde(rename = "VaultID")] + pub vault_id: Cow<'a, str>, + /// The amount of the asset to deposit into the vault. + pub amount: Amount<'a>, +} + +impl Model for VaultDeposit<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + self.validate_currencies() + } +} + +impl<'a> Transaction<'a, NoFlags> for VaultDeposit<'a> { + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + &self.common_fields + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for VaultDeposit<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> VaultDeposit<'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, + vault_id: Cow<'a, str>, + amount: Amount<'a>, + ) -> VaultDeposit<'a> { + VaultDeposit { + common_fields: CommonFields::new( + account, + TransactionType::VaultDeposit, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + vault_id, + amount, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{IssuedCurrencyAmount, XRPAmount}; + + const VAULT_ID: &str = "A0000000000000000000000000000000000000000000000000000000DEADBEEF"; + + #[test] + fn test_serde() { + let vault_deposit = VaultDeposit { + common_fields: CommonFields { + account: "rDepositor123".into(), + transaction_type: TransactionType::VaultDeposit, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + }; + + let json_str = r#"{"Account":"rDepositor123","TransactionType":"VaultDeposit","Flags":0,"SigningPubKey":"","VaultID":"A0000000000000000000000000000000000000000000000000000000DEADBEEF","Amount":"1000000"}"#; + + // Serialize + let serialized = serde_json::to_string(&vault_deposit).unwrap(); + assert_eq!( + serde_json::to_value(&serialized).unwrap(), + serde_json::to_value(json_str).unwrap() + ); + + // Deserialize + let deserialized: VaultDeposit = serde_json::from_str(json_str).unwrap(); + assert_eq!(vault_deposit, deserialized); + } + + #[test] + fn test_serde_issued_currency() { + let vault_deposit = VaultDeposit { + common_fields: CommonFields { + account: "rDepositorICA456".into(), + transaction_type: TransactionType::VaultDeposit, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( + "USD".into(), + "rIssuer789".into(), + "1000".into(), + )), + }; + + let serialized = serde_json::to_string(&vault_deposit).unwrap(); + let deserialized: VaultDeposit = serde_json::from_str(&serialized).unwrap(); + assert_eq!(vault_deposit, deserialized); + } + + #[test] + fn test_builder_pattern() { + let vault_deposit = VaultDeposit { + common_fields: CommonFields { + account: "rDepositor123".into(), + transaction_type: TransactionType::VaultDeposit, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + } + .with_fee("12".into()) + .with_sequence(100) + .with_last_ledger_sequence(7108682) + .with_source_tag(12345) + .with_memo(Memo { + memo_data: Some("depositing into vault".into()), + memo_format: None, + memo_type: Some("text".into()), + }); + + assert_eq!(vault_deposit.vault_id, VAULT_ID); + assert_eq!(vault_deposit.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(vault_deposit.common_fields.sequence, Some(100)); + assert_eq!( + vault_deposit.common_fields.last_ledger_sequence, + Some(7108682) + ); + assert_eq!(vault_deposit.common_fields.source_tag, Some(12345)); + assert_eq!(vault_deposit.common_fields.memos.as_ref().unwrap().len(), 1); + } + + #[test] + fn test_default() { + let vault_deposit = VaultDeposit { + common_fields: CommonFields { + account: "rDepositor789".into(), + transaction_type: TransactionType::VaultDeposit, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("5000000")), + }; + + assert_eq!(vault_deposit.common_fields.account, "rDepositor789"); + assert_eq!( + vault_deposit.common_fields.transaction_type, + TransactionType::VaultDeposit + ); + assert_eq!(vault_deposit.vault_id, VAULT_ID); + assert!(vault_deposit.common_fields.fee.is_none()); + assert!(vault_deposit.common_fields.sequence.is_none()); + } + + #[test] + fn test_ticket_sequence() { + let ticket_deposit = VaultDeposit { + common_fields: CommonFields { + account: "rTicketDepositor111".into(), + transaction_type: TransactionType::VaultDeposit, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("2000000")), + } + .with_ticket_sequence(54321) + .with_fee("12".into()); + + assert_eq!(ticket_deposit.common_fields.ticket_sequence, Some(54321)); + assert!(ticket_deposit.common_fields.sequence.is_none()); + } + + #[test] + fn test_multiple_memos() { + let multi_memo_deposit = VaultDeposit { + common_fields: CommonFields { + account: "rMultiMemoDepositor222".into(), + transaction_type: TransactionType::VaultDeposit, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( + "USD".into(), + "rUSDIssuer333".into(), + "500".into(), + )), + } + .with_memo(Memo { + memo_data: Some("first deposit".into()), + memo_format: None, + memo_type: Some("text".into()), + }) + .with_memo(Memo { + memo_data: Some("additional note".into()), + memo_format: None, + memo_type: Some("text".into()), + }) + .with_fee("18".into()) + .with_sequence(400); + + assert_eq!( + multi_memo_deposit + .common_fields + .memos + .as_ref() + .unwrap() + .len(), + 2 + ); + assert_eq!(multi_memo_deposit.common_fields.sequence, Some(400)); + } + + #[test] + fn test_new_constructor() { + let vault_deposit = VaultDeposit::new( + "rNewDepositor444".into(), + None, + Some("12".into()), + Some(7108682), + None, + Some(100), + None, + None, + None, + VAULT_ID.into(), + Amount::XRPAmount(XRPAmount::from("10000000")), + ); + + assert_eq!(vault_deposit.common_fields.account, "rNewDepositor444"); + assert_eq!( + vault_deposit.common_fields.transaction_type, + TransactionType::VaultDeposit + ); + assert_eq!(vault_deposit.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(vault_deposit.vault_id, VAULT_ID); + } + + #[test] + fn test_validate() { + let vault_deposit = VaultDeposit { + common_fields: CommonFields { + account: "rValidateDepositor555".into(), + transaction_type: TransactionType::VaultDeposit, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + } + .with_fee("12".into()) + .with_sequence(300); + + assert!(vault_deposit.validate().is_ok()); + } +} diff --git a/src/models/transactions/vault_set.rs b/src/models/transactions/vault_set.rs new file mode 100644 index 00000000..59d3f4b2 --- /dev/null +++ b/src/models/transactions/vault_set.rs @@ -0,0 +1,360 @@ +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::{FlagCollection, Model, NoFlags, XRPLModelResult}; + +use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType}; + +/// Update the settings of an existing vault on the XRP Ledger (XLS-65). +/// +/// Only the vault owner can submit this transaction. It allows updating +/// optional metadata fields such as data, assets maximum, and domain ID. +/// +/// See VaultSet transaction: +/// `` +#[skip_serializing_none] +#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct VaultSet<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The ID of the vault to update (256-bit hex string). + #[serde(rename = "VaultID")] + pub vault_id: Cow<'a, str>, + /// Arbitrary hex-encoded data associated with the vault. + pub data: Option>, + /// The maximum amount of assets the vault can hold, as a string-encoded integer. + pub assets_maximum: Option>, + /// The domain ID associated with the vault. + #[serde(rename = "DomainID")] + pub domain_id: Option>, +} + +impl Model for VaultSet<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + Ok(()) + } +} + +impl<'a> Transaction<'a, NoFlags> for VaultSet<'a> { + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + &self.common_fields + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for VaultSet<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> VaultSet<'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, + vault_id: Cow<'a, str>, + data: Option>, + assets_maximum: Option>, + domain_id: Option>, + ) -> VaultSet<'a> { + VaultSet { + common_fields: CommonFields::new( + account, + TransactionType::VaultSet, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + vault_id, + data, + assets_maximum, + domain_id, + } + } + + /// Set the data field. + pub fn with_data(mut self, data: Cow<'a, str>) -> Self { + self.data = Some(data); + self + } + + /// Set the assets maximum field. + pub fn with_assets_maximum(mut self, assets_maximum: Cow<'a, str>) -> Self { + self.assets_maximum = Some(assets_maximum); + self + } + + /// Set the domain ID field. + pub fn with_domain_id(mut self, domain_id: Cow<'a, str>) -> Self { + self.domain_id = Some(domain_id); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VAULT_ID: &str = "A0000000000000000000000000000000000000000000000000000000DEADBEEF"; + + #[test] + fn test_serde() { + let vault_set = VaultSet { + common_fields: CommonFields { + account: "rVaultOwner123".into(), + transaction_type: TransactionType::VaultSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + data: Some("48656C6C6F".into()), + assets_maximum: None, + domain_id: None, + }; + + let json_str = r#"{"Account":"rVaultOwner123","TransactionType":"VaultSet","Flags":0,"SigningPubKey":"","VaultID":"A0000000000000000000000000000000000000000000000000000000DEADBEEF","Data":"48656C6C6F"}"#; + + // Serialize + let serialized = serde_json::to_string(&vault_set).unwrap(); + assert_eq!( + serde_json::to_value(&serialized).unwrap(), + serde_json::to_value(json_str).unwrap() + ); + + // Deserialize + let deserialized: VaultSet = serde_json::from_str(json_str).unwrap(); + assert_eq!(vault_set, deserialized); + } + + #[test] + fn test_serde_all_optional_fields() { + let vault_set = VaultSet { + common_fields: CommonFields { + account: "rVaultOwnerAll456".into(), + transaction_type: TransactionType::VaultSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + data: Some("48656C6C6F".into()), + assets_maximum: Some("2000000000".into()), + domain_id: Some( + "D0000000000000000000000000000000000000000000000000000000DEADBEEF".into(), + ), + }; + + let serialized = serde_json::to_string(&vault_set).unwrap(); + let deserialized: VaultSet = serde_json::from_str(&serialized).unwrap(); + assert_eq!(vault_set, deserialized); + } + + #[test] + fn test_builder_pattern() { + let vault_set = VaultSet { + common_fields: CommonFields { + account: "rVaultOwner123".into(), + transaction_type: TransactionType::VaultSet, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + ..Default::default() + } + .with_fee("12".into()) + .with_sequence(100) + .with_last_ledger_sequence(7108682) + .with_source_tag(12345) + .with_data("48656C6C6F".into()) + .with_assets_maximum("2000000000".into()) + .with_domain_id("D0000000000000000000000000000000000000000000000000000000DEADBEEF".into()) + .with_memo(Memo { + memo_data: Some("updating vault settings".into()), + memo_format: None, + memo_type: Some("text".into()), + }); + + assert_eq!(vault_set.vault_id, VAULT_ID); + assert_eq!(vault_set.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(vault_set.common_fields.sequence, Some(100)); + assert_eq!(vault_set.common_fields.last_ledger_sequence, Some(7108682)); + assert_eq!(vault_set.common_fields.source_tag, Some(12345)); + assert_eq!(vault_set.data, Some("48656C6C6F".into())); + assert_eq!(vault_set.assets_maximum, Some("2000000000".into())); + assert_eq!(vault_set.common_fields.memos.as_ref().unwrap().len(), 1); + } + + #[test] + fn test_default() { + let vault_set = VaultSet { + common_fields: CommonFields { + account: "rVaultOwner789".into(), + transaction_type: TransactionType::VaultSet, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + ..Default::default() + }; + + assert_eq!(vault_set.common_fields.account, "rVaultOwner789"); + assert_eq!( + vault_set.common_fields.transaction_type, + TransactionType::VaultSet + ); + assert_eq!(vault_set.vault_id, VAULT_ID); + assert!(vault_set.data.is_none()); + assert!(vault_set.assets_maximum.is_none()); + assert!(vault_set.domain_id.is_none()); + assert!(vault_set.common_fields.fee.is_none()); + assert!(vault_set.common_fields.sequence.is_none()); + } + + #[test] + fn test_ticket_sequence() { + let ticket_set = VaultSet { + common_fields: CommonFields { + account: "rTicketVaultSet111".into(), + transaction_type: TransactionType::VaultSet, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + ..Default::default() + } + .with_ticket_sequence(54321) + .with_fee("12".into()); + + assert_eq!(ticket_set.common_fields.ticket_sequence, Some(54321)); + assert!(ticket_set.common_fields.sequence.is_none()); + } + + #[test] + fn test_multiple_memos() { + let multi_memo_set = VaultSet { + common_fields: CommonFields { + account: "rMultiMemoVaultSet222".into(), + transaction_type: TransactionType::VaultSet, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + ..Default::default() + } + .with_memo(Memo { + memo_data: Some("first update".into()), + memo_format: None, + memo_type: Some("text".into()), + }) + .with_memo(Memo { + memo_data: Some("second update".into()), + memo_format: None, + memo_type: Some("text".into()), + }) + .with_fee("18".into()) + .with_sequence(400); + + assert_eq!( + multi_memo_set.common_fields.memos.as_ref().unwrap().len(), + 2 + ); + assert_eq!(multi_memo_set.common_fields.sequence, Some(400)); + } + + #[test] + fn test_new_constructor() { + let vault_set = VaultSet::new( + "rNewVaultSetter333".into(), + None, + Some("12".into()), + Some(7108682), + None, + Some(100), + None, + None, + None, + VAULT_ID.into(), + Some("48656C6C6F".into()), + Some("2000000000".into()), + Some("D0000000000000000000000000000000000000000000000000000000DEADBEEF".into()), + ); + + assert_eq!(vault_set.common_fields.account, "rNewVaultSetter333"); + assert_eq!( + vault_set.common_fields.transaction_type, + TransactionType::VaultSet + ); + assert_eq!(vault_set.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(vault_set.vault_id, VAULT_ID); + assert_eq!(vault_set.data, Some("48656C6C6F".into())); + assert_eq!(vault_set.assets_maximum, Some("2000000000".into())); + } + + #[test] + fn test_validate() { + let vault_set = VaultSet { + common_fields: CommonFields { + account: "rValidateVaultSet444".into(), + transaction_type: TransactionType::VaultSet, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + ..Default::default() + } + .with_fee("12".into()) + .with_sequence(300); + + assert!(vault_set.validate().is_ok()); + } + + #[test] + fn test_update_data_only() { + let vault_set = VaultSet { + common_fields: CommonFields { + account: "rDataOnlyUpdate555".into(), + transaction_type: TransactionType::VaultSet, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + ..Default::default() + } + .with_data("4E6577446174614F6E6C79".into()) + .with_fee("12".into()) + .with_sequence(500); + + assert_eq!(vault_set.data, Some("4E6577446174614F6E6C79".into())); + assert!(vault_set.assets_maximum.is_none()); + assert!(vault_set.domain_id.is_none()); + assert!(vault_set.validate().is_ok()); + } +} diff --git a/src/models/transactions/vault_withdraw.rs b/src/models/transactions/vault_withdraw.rs new file mode 100644 index 00000000..736da1d6 --- /dev/null +++ b/src/models/transactions/vault_withdraw.rs @@ -0,0 +1,323 @@ +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::{Amount, FlagCollection, Model, NoFlags, ValidateCurrencies, XRPLModelResult}; + +use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType}; + +/// Withdraw assets from a vault on the XRP Ledger (XLS-65). +/// +/// The withdrawer burns share tokens (MPTokens) in exchange for the +/// proportional share of the vault's assets. +/// +/// See VaultWithdraw transaction: +/// `` +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct VaultWithdraw<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The ID of the vault to withdraw from (256-bit hex string). + #[serde(rename = "VaultID")] + pub vault_id: Cow<'a, str>, + /// The amount of the asset to withdraw from the vault. + pub amount: Amount<'a>, +} + +impl Model for VaultWithdraw<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + self.validate_currencies() + } +} + +impl<'a> Transaction<'a, NoFlags> for VaultWithdraw<'a> { + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + &self.common_fields + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for VaultWithdraw<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> VaultWithdraw<'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, + vault_id: Cow<'a, str>, + amount: Amount<'a>, + ) -> VaultWithdraw<'a> { + VaultWithdraw { + common_fields: CommonFields::new( + account, + TransactionType::VaultWithdraw, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + vault_id, + amount, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{IssuedCurrencyAmount, XRPAmount}; + + const VAULT_ID: &str = "A0000000000000000000000000000000000000000000000000000000DEADBEEF"; + + #[test] + fn test_serde() { + let vault_withdraw = VaultWithdraw { + common_fields: CommonFields { + account: "rWithdrawer123".into(), + transaction_type: TransactionType::VaultWithdraw, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + }; + + let json_str = r#"{"Account":"rWithdrawer123","TransactionType":"VaultWithdraw","Flags":0,"SigningPubKey":"","VaultID":"A0000000000000000000000000000000000000000000000000000000DEADBEEF","Amount":"1000000"}"#; + + // Serialize + let serialized = serde_json::to_string(&vault_withdraw).unwrap(); + assert_eq!( + serde_json::to_value(&serialized).unwrap(), + serde_json::to_value(json_str).unwrap() + ); + + // Deserialize + let deserialized: VaultWithdraw = serde_json::from_str(json_str).unwrap(); + assert_eq!(vault_withdraw, deserialized); + } + + #[test] + fn test_serde_issued_currency() { + let vault_withdraw = VaultWithdraw { + common_fields: CommonFields { + account: "rWithdrawICA456".into(), + transaction_type: TransactionType::VaultWithdraw, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( + "USD".into(), + "rIssuer789".into(), + "500".into(), + )), + }; + + let serialized = serde_json::to_string(&vault_withdraw).unwrap(); + let deserialized: VaultWithdraw = serde_json::from_str(&serialized).unwrap(); + assert_eq!(vault_withdraw, deserialized); + } + + #[test] + fn test_builder_pattern() { + let vault_withdraw = VaultWithdraw { + common_fields: CommonFields { + account: "rWithdrawer123".into(), + transaction_type: TransactionType::VaultWithdraw, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + } + .with_fee("12".into()) + .with_sequence(100) + .with_last_ledger_sequence(7108682) + .with_source_tag(12345) + .with_memo(Memo { + memo_data: Some("withdrawing from vault".into()), + memo_format: None, + memo_type: Some("text".into()), + }); + + assert_eq!(vault_withdraw.vault_id, VAULT_ID); + assert_eq!(vault_withdraw.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(vault_withdraw.common_fields.sequence, Some(100)); + assert_eq!( + vault_withdraw.common_fields.last_ledger_sequence, + Some(7108682) + ); + assert_eq!(vault_withdraw.common_fields.source_tag, Some(12345)); + assert_eq!( + vault_withdraw.common_fields.memos.as_ref().unwrap().len(), + 1 + ); + } + + #[test] + fn test_default() { + let vault_withdraw = VaultWithdraw { + common_fields: CommonFields { + account: "rWithdrawer789".into(), + transaction_type: TransactionType::VaultWithdraw, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("5000000")), + }; + + assert_eq!(vault_withdraw.common_fields.account, "rWithdrawer789"); + assert_eq!( + vault_withdraw.common_fields.transaction_type, + TransactionType::VaultWithdraw + ); + assert_eq!(vault_withdraw.vault_id, VAULT_ID); + assert!(vault_withdraw.common_fields.fee.is_none()); + assert!(vault_withdraw.common_fields.sequence.is_none()); + } + + #[test] + fn test_ticket_sequence() { + let ticket_withdraw = VaultWithdraw { + common_fields: CommonFields { + account: "rTicketWithdrawer111".into(), + transaction_type: TransactionType::VaultWithdraw, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("2000000")), + } + .with_ticket_sequence(54321) + .with_fee("12".into()); + + assert_eq!(ticket_withdraw.common_fields.ticket_sequence, Some(54321)); + assert!(ticket_withdraw.common_fields.sequence.is_none()); + } + + #[test] + fn test_multiple_memos() { + let multi_memo_withdraw = VaultWithdraw { + common_fields: CommonFields { + account: "rMultiMemoWithdrawer222".into(), + transaction_type: TransactionType::VaultWithdraw, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( + "USD".into(), + "rUSDIssuer333".into(), + "250".into(), + )), + } + .with_memo(Memo { + memo_data: Some("partial withdrawal".into()), + memo_format: None, + memo_type: Some("text".into()), + }) + .with_memo(Memo { + memo_data: Some("rebalancing portfolio".into()), + memo_format: None, + memo_type: Some("text".into()), + }) + .with_fee("18".into()) + .with_sequence(400); + + assert_eq!( + multi_memo_withdraw + .common_fields + .memos + .as_ref() + .unwrap() + .len(), + 2 + ); + assert_eq!(multi_memo_withdraw.common_fields.sequence, Some(400)); + } + + #[test] + fn test_new_constructor() { + let vault_withdraw = VaultWithdraw::new( + "rNewWithdrawer444".into(), + None, + Some("12".into()), + Some(7108682), + None, + Some(100), + None, + None, + None, + VAULT_ID.into(), + Amount::XRPAmount(XRPAmount::from("10000000")), + ); + + assert_eq!(vault_withdraw.common_fields.account, "rNewWithdrawer444"); + assert_eq!( + vault_withdraw.common_fields.transaction_type, + TransactionType::VaultWithdraw + ); + assert_eq!(vault_withdraw.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(vault_withdraw.vault_id, VAULT_ID); + } + + #[test] + fn test_validate() { + let vault_withdraw = VaultWithdraw { + common_fields: CommonFields { + account: "rValidateWithdrawer555".into(), + transaction_type: TransactionType::VaultWithdraw, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + } + .with_fee("12".into()) + .with_sequence(300); + + assert!(vault_withdraw.validate().is_ok()); + } +} From 1bfcc2329ca32225a8b078daabd4c81836ea4e6d Mon Sep 17 00:00:00 2001 From: e-desouza Date: Wed, 1 Apr 2026 22:45:10 +0000 Subject: [PATCH 2/7] feat: add Vault ledger entry type (XLS-65) Add the Vault ledger object with LedgerEntryType::Vault (0x0084) and the corresponding LedgerEntry::Vault variant. The Vault struct models the on-ledger state including asset, share tokens, totals, and metadata fields per the XLS-65 specification. --- src/models/ledger/objects/mod.rs | 4 + src/models/ledger/objects/vault.rs | 207 +++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 src/models/ledger/objects/vault.rs diff --git a/src/models/ledger/objects/mod.rs b/src/models/ledger/objects/mod.rs index 45fa941f..ea3e9c6e 100644 --- a/src/models/ledger/objects/mod.rs +++ b/src/models/ledger/objects/mod.rs @@ -16,6 +16,7 @@ pub mod pay_channel; pub mod ripple_state; pub mod signer_list; pub mod ticket; +pub mod vault; pub mod xchain_owned_claim_id; pub mod xchain_owned_create_account_claim_id; @@ -44,6 +45,7 @@ use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use strum_macros::Display; use ticket::Ticket; +use vault::Vault; use xchain_owned_claim_id::XChainOwnedClaimID; use xchain_owned_create_account_claim_id::XChainOwnedCreateAccountClaimID; @@ -70,6 +72,7 @@ pub enum LedgerEntryType { RippleState = 0x0072, SignerList = 0x0053, Ticket = 0x0054, + Vault = 0x0084, XChainOwnedClaimID = 0x0071, XChainOwnedCreateAccountClaimID = 0x0074, } @@ -94,6 +97,7 @@ pub enum LedgerEntry<'a> { RippleState(RippleState<'a>), SignerList(SignerList<'a>), Ticket(Ticket<'a>), + Vault(Vault<'a>), XChainOwnedClaimID(XChainOwnedClaimID<'a>), XChainOwnedCreateAccountClaimID(XChainOwnedCreateAccountClaimID<'a>), } diff --git a/src/models/ledger/objects/vault.rs b/src/models/ledger/objects/vault.rs new file mode 100644 index 00000000..79c0cb47 --- /dev/null +++ b/src/models/ledger/objects/vault.rs @@ -0,0 +1,207 @@ +use crate::models::ledger::objects::LedgerEntryType; +use crate::models::{Amount, Currency, FlagCollection, Model, NoFlags}; +use alloc::borrow::Cow; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use super::{CommonFields, LedgerObject}; + +/// The `Vault` object type describes a single-asset vault instance (XLS-65). +/// +/// A vault holds a single asset type and issues share tokens (MPTokens) +/// to depositors proportional to their ownership of the vault's assets. +/// +/// `` +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct Vault<'a> { + /// The base fields for all ledger object models. + /// + /// See Ledger Object Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The account that created and owns this vault. + pub account: Cow<'a, str>, + /// The asset that this vault holds. + pub asset: Currency<'a>, + /// The total amount of assets currently held in the vault. + pub assets_total: Option>, + /// The amount of assets available for withdrawal. + pub assets_available: Option>, + /// The maximum amount of assets the vault can hold. + pub assets_maximum: Option>, + /// The liquidity provider token balance for this vault. + #[serde(rename = "LPToken")] + pub lp_token: Option>, + /// The share token for this vault. + pub share: Option>, + /// Arbitrary hex-encoded data associated with the vault. + pub data: Option>, + /// The ID of the MPToken issuance associated with this vault. + #[serde(rename = "MPTokenIssuanceID")] + pub mpt_issuance_id: Option>, + /// The domain ID associated with the vault. + #[serde(rename = "DomainID")] + pub domain_id: Option>, + /// A hint indicating which page of the owner's directory links to this object. + pub owner_node: Option>, + /// The identifying hash of the transaction that most recently modified this object. + #[serde(rename = "PreviousTxnID")] + pub previous_txn_id: Cow<'a, str>, + /// The index of the ledger that contains the transaction that most recently modified + /// this object. + pub previous_txn_lgr_seq: u32, +} + +impl<'a> Model for Vault<'a> {} + +impl<'a> LedgerObject for Vault<'a> { + fn get_ledger_entry_type(&self) -> LedgerEntryType { + self.common_fields.get_ledger_entry_type() + } +} + +impl<'a> Vault<'a> { + pub fn new( + index: Option>, + ledger_index: Option>, + account: Cow<'a, str>, + asset: Currency<'a>, + assets_total: Option>, + assets_available: Option>, + assets_maximum: Option>, + lp_token: Option>, + share: Option>, + data: Option>, + mpt_issuance_id: Option>, + domain_id: Option>, + owner_node: Option>, + previous_txn_id: Cow<'a, str>, + previous_txn_lgr_seq: u32, + ) -> Self { + Self { + common_fields: CommonFields { + flags: FlagCollection::default(), + ledger_entry_type: LedgerEntryType::Vault, + index, + ledger_index, + }, + account, + asset, + assets_total, + assets_available, + assets_maximum, + lp_token, + share, + data, + mpt_issuance_id, + domain_id, + owner_node, + previous_txn_id, + previous_txn_lgr_seq, + } + } +} + +#[cfg(test)] +mod test_serde { + use crate::models::amount::IssuedCurrencyAmount; + use crate::models::currency::{Currency, IssuedCurrency}; + use crate::models::ledger::objects::vault::Vault; + use alloc::borrow::Cow; + + #[test] + fn test_serialize() { + let vault = Vault::new( + Some(Cow::from("ForTest")), + None, + Cow::from("rVaultOwner123"), + Currency::IssuedCurrency(IssuedCurrency::new("USD".into(), "rIssuer456".into())), + Some("1000000".into()), + Some("800000".into()), + Some("5000000".into()), + None, + Some(crate::models::Amount::IssuedCurrencyAmount( + IssuedCurrencyAmount::new( + "039C99CD9AB0B70B32ECDA51EAAE471625608EA2".into(), + "rVaultOwner123".into(), + "1000".into(), + ), + )), + Some("48656C6C6F".into()), + Some("0000000000000001".into()), + None, + Some("0".into()), + Cow::from("ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890"), + 12345678, + ); + + let serialized = serde_json::to_string(&vault).unwrap(); + let deserialized: Vault = serde_json::from_str(&serialized).unwrap(); + assert_eq!(vault, deserialized); + } + + #[test] + fn test_minimal_vault() { + let vault = Vault::new( + Some(Cow::from("MinimalTest")), + None, + Cow::from("rMinimalVault789"), + Currency::IssuedCurrency(IssuedCurrency::new("EUR".into(), "rEURIssuer012".into())), + None, + None, + None, + None, + None, + None, + None, + None, + None, + Cow::from("1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF"), + 1, + ); + + let serialized = serde_json::to_string(&vault).unwrap(); + let deserialized: Vault = serde_json::from_str(&serialized).unwrap(); + assert_eq!(vault, deserialized); + } + + #[test] + fn test_vault_with_all_fields() { + let vault = Vault::new( + Some(Cow::from("FullVaultTest")), + Some(Cow::from("ledger_idx_123")), + Cow::from("rFullVaultOwner456"), + Currency::IssuedCurrency(IssuedCurrency::new("BTC".into(), "rBTCIssuer789".into())), + Some("50000000".into()), + Some("45000000".into()), + Some("100000000".into()), + Some(crate::models::Amount::IssuedCurrencyAmount( + IssuedCurrencyAmount::new( + "LP_TOKEN_CURRENCY".into(), + "rFullVaultOwner456".into(), + "5000".into(), + ), + )), + Some(crate::models::Amount::IssuedCurrencyAmount( + IssuedCurrencyAmount::new( + "SHARE_CURRENCY".into(), + "rFullVaultOwner456".into(), + "2500".into(), + ), + )), + Some("44617461".into()), + Some("00000000DEADBEEF".into()), + Some("D0000000000000000000000000000000000000000000000000000000DEADBEEF".into()), + Some("42".into()), + Cow::from("FEDCBA0987654321FEDCBA0987654321FEDCBA0987654321FEDCBA0987654321"), + 99999999, + ); + + let serialized = serde_json::to_string(&vault).unwrap(); + let deserialized: Vault = serde_json::from_str(&serialized).unwrap(); + assert_eq!(vault, deserialized); + } +} From 2976bdd296e484b1aeb505ccc825632885472723 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Wed, 1 Apr 2026 22:45:16 +0000 Subject: [PATCH 3/7] test: add integration test stubs for vault transaction types Add integration test files for all six XLS-65 vault transaction types covering serde roundtrip, builder pattern, and validation. These will be extended with live submission tests once XLS-65 is available on devnet. --- tests/transactions/mod.rs | 6 ++ tests/transactions/vault_clawback.rs | 84 ++++++++++++++++++++++++++++ tests/transactions/vault_create.rs | 81 +++++++++++++++++++++++++++ tests/transactions/vault_delete.rs | 52 +++++++++++++++++ tests/transactions/vault_deposit.rs | 77 +++++++++++++++++++++++++ tests/transactions/vault_set.rs | 77 +++++++++++++++++++++++++ tests/transactions/vault_withdraw.rs | 77 +++++++++++++++++++++++++ 7 files changed, 454 insertions(+) create mode 100644 tests/transactions/vault_clawback.rs create mode 100644 tests/transactions/vault_create.rs create mode 100644 tests/transactions/vault_delete.rs create mode 100644 tests/transactions/vault_deposit.rs create mode 100644 tests/transactions/vault_set.rs create mode 100644 tests/transactions/vault_withdraw.rs diff --git a/tests/transactions/mod.rs b/tests/transactions/mod.rs index c0e13b8d..2e3fbc7d 100644 --- a/tests/transactions/mod.rs +++ b/tests/transactions/mod.rs @@ -28,6 +28,12 @@ pub mod set_regular_key; pub mod signer_list_set; pub mod ticket_create; pub mod trust_set; +pub mod vault_clawback; +pub mod vault_create; +pub mod vault_delete; +pub mod vault_deposit; +pub mod vault_set; +pub mod vault_withdraw; pub mod xchain_account_create_commit; pub mod xchain_add_account_create_attestation; pub mod xchain_add_claim_attestation; diff --git a/tests/transactions/vault_clawback.rs b/tests/transactions/vault_clawback.rs new file mode 100644 index 00000000..92310071 --- /dev/null +++ b/tests/transactions/vault_clawback.rs @@ -0,0 +1,84 @@ +// XLS-65 SingleAssetVault — VaultClawback integration test stub +// +// VaultClawback requires an XLS-65-enabled rippled node. These tests validate +// that the transaction type can be constructed and serialized correctly. + +use xrpl::models::transactions::vault_clawback::VaultClawback; +use xrpl::models::transactions::{CommonFields, Memo, TransactionType}; +use xrpl::models::{Amount, IssuedCurrencyAmount, Model, XRPAmount}; + +const VAULT_ID: &str = "A0000000000000000000000000000000000000000000000000000000DEADBEEF"; + +#[test] +fn test_vault_clawback_serde_roundtrip() { + let vault_clawback = VaultClawback { + common_fields: CommonFields { + account: "rIssuer123".into(), + transaction_type: TransactionType::VaultClawback, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + holder: "rHolder456".into(), + amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( + "USD".into(), + "rIssuer123".into(), + "500".into(), + )), + }; + + let json_str = serde_json::to_string(&vault_clawback).unwrap(); + let deserialized: VaultClawback = serde_json::from_str(&json_str).unwrap(); + assert_eq!(vault_clawback, deserialized); + assert!(vault_clawback.validate().is_ok()); +} + +#[test] +fn test_vault_clawback_xrp_amount() { + let vault_clawback = VaultClawback { + common_fields: CommonFields { + account: "rIssuerXRP".into(), + transaction_type: TransactionType::VaultClawback, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + holder: "rHolderXRP".into(), + amount: Amount::XRPAmount(XRPAmount::from("2000000")), + }; + + let json_str = serde_json::to_string(&vault_clawback).unwrap(); + let deserialized: VaultClawback = serde_json::from_str(&json_str).unwrap(); + assert_eq!(vault_clawback, deserialized); +} + +#[test] +fn test_vault_clawback_builder_pattern() { + use xrpl::models::transactions::CommonTransactionBuilder; + + let vault_clawback = VaultClawback { + common_fields: CommonFields { + account: "rClawBuilder".into(), + transaction_type: TransactionType::VaultClawback, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + holder: "rTarget".into(), + amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( + "EUR".into(), + "rClawBuilder".into(), + "1000".into(), + )), + } + .with_fee("12".into()) + .with_sequence(600) + .with_memo(Memo { + memo_data: Some("compliance clawback".into()), + memo_format: None, + memo_type: Some("text".into()), + }); + + assert_eq!(vault_clawback.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(vault_clawback.common_fields.sequence, Some(600)); + assert!(vault_clawback.validate().is_ok()); +} diff --git a/tests/transactions/vault_create.rs b/tests/transactions/vault_create.rs new file mode 100644 index 00000000..f52199e7 --- /dev/null +++ b/tests/transactions/vault_create.rs @@ -0,0 +1,81 @@ +// XLS-65 SingleAssetVault — VaultCreate integration test stub +// +// VaultCreate requires an XLS-65-enabled rippled node. These tests validate +// that the transaction type can be constructed and serialized correctly. +// Live submission tests will be enabled once XLS-65 is available on devnet. + +use xrpl::models::transactions::vault_create::VaultCreate; +use xrpl::models::transactions::{CommonFields, Memo, TransactionType}; +use xrpl::models::{Currency, IssuedCurrency, Model, XRP}; + +#[test] +fn test_vault_create_serde_roundtrip() { + let vault_create = VaultCreate { + common_fields: CommonFields { + account: "rVaultCreator123".into(), + transaction_type: TransactionType::VaultCreate, + signing_pub_key: Some("".into()), + ..Default::default() + }, + asset: Currency::IssuedCurrency(IssuedCurrency::new("USD".into(), "rIssuer456".into())), + data: None, + assets_maximum: None, + mptoken_metadata: None, + domain_id: None, + withdrawal_policy: None, + }; + + let json_str = serde_json::to_string(&vault_create).unwrap(); + let deserialized: VaultCreate = serde_json::from_str(&json_str).unwrap(); + assert_eq!(vault_create, deserialized); +} + +#[test] +fn test_vault_create_with_all_optional_fields() { + let vault_create = VaultCreate { + common_fields: CommonFields { + account: "rVaultCreatorFull".into(), + transaction_type: TransactionType::VaultCreate, + fee: Some("12".into()), + sequence: Some(100), + ..Default::default() + }, + asset: Currency::XRP(XRP::new()), + data: Some("48656C6C6F".into()), + assets_maximum: Some("1000000000".into()), + mptoken_metadata: Some("ABCDEF".into()), + domain_id: Some("D0000000000000000000000000000000000000000000000000000000DEADBEEF".into()), + withdrawal_policy: Some(1), + }; + + assert!(vault_create.validate().is_ok()); + let json_str = serde_json::to_string(&vault_create).unwrap(); + let deserialized: VaultCreate = serde_json::from_str(&json_str).unwrap(); + assert_eq!(vault_create, deserialized); +} + +#[test] +fn test_vault_create_builder_pattern() { + use xrpl::models::transactions::CommonTransactionBuilder; + + let vault_create = VaultCreate { + common_fields: CommonFields { + account: "rVaultBuilder".into(), + transaction_type: TransactionType::VaultCreate, + ..Default::default() + }, + asset: Currency::IssuedCurrency(IssuedCurrency::new("EUR".into(), "rEURIssuer".into())), + ..Default::default() + } + .with_fee("15".into()) + .with_sequence(200) + .with_memo(Memo { + memo_data: Some("vault creation".into()), + memo_format: None, + memo_type: Some("text".into()), + }); + + assert_eq!(vault_create.common_fields.fee.as_ref().unwrap().0, "15"); + assert_eq!(vault_create.common_fields.sequence, Some(200)); + assert!(vault_create.common_fields.memos.is_some()); +} diff --git a/tests/transactions/vault_delete.rs b/tests/transactions/vault_delete.rs new file mode 100644 index 00000000..4b456941 --- /dev/null +++ b/tests/transactions/vault_delete.rs @@ -0,0 +1,52 @@ +// XLS-65 SingleAssetVault — VaultDelete integration test stub +// +// VaultDelete requires an XLS-65-enabled rippled node. These tests validate +// that the transaction type can be constructed and serialized correctly. + +use xrpl::models::transactions::vault_delete::VaultDelete; +use xrpl::models::transactions::{CommonFields, Memo, TransactionType}; +use xrpl::models::Model; + +const VAULT_ID: &str = "A0000000000000000000000000000000000000000000000000000000DEADBEEF"; + +#[test] +fn test_vault_delete_serde_roundtrip() { + let vault_delete = VaultDelete { + common_fields: CommonFields { + account: "rVaultOwner123".into(), + transaction_type: TransactionType::VaultDelete, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + }; + + let json_str = serde_json::to_string(&vault_delete).unwrap(); + let deserialized: VaultDelete = serde_json::from_str(&json_str).unwrap(); + assert_eq!(vault_delete, deserialized); +} + +#[test] +fn test_vault_delete_builder_pattern() { + use xrpl::models::transactions::CommonTransactionBuilder; + + let vault_delete = VaultDelete { + common_fields: CommonFields { + account: "rVaultDelBuilder".into(), + transaction_type: TransactionType::VaultDelete, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + } + .with_fee("12".into()) + .with_sequence(300) + .with_memo(Memo { + memo_data: Some("vault deletion".into()), + memo_format: None, + memo_type: Some("text".into()), + }); + + assert_eq!(vault_delete.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(vault_delete.common_fields.sequence, Some(300)); + assert!(vault_delete.validate().is_ok()); +} diff --git a/tests/transactions/vault_deposit.rs b/tests/transactions/vault_deposit.rs new file mode 100644 index 00000000..363cf8c3 --- /dev/null +++ b/tests/transactions/vault_deposit.rs @@ -0,0 +1,77 @@ +// XLS-65 SingleAssetVault — VaultDeposit integration test stub +// +// VaultDeposit requires an XLS-65-enabled rippled node. These tests validate +// that the transaction type can be constructed and serialized correctly. + +use xrpl::models::transactions::vault_deposit::VaultDeposit; +use xrpl::models::transactions::{CommonFields, Memo, TransactionType}; +use xrpl::models::{Amount, IssuedCurrencyAmount, Model, XRPAmount}; + +const VAULT_ID: &str = "A0000000000000000000000000000000000000000000000000000000DEADBEEF"; + +#[test] +fn test_vault_deposit_serde_roundtrip_xrp() { + let vault_deposit = VaultDeposit { + common_fields: CommonFields { + account: "rDepositor123".into(), + transaction_type: TransactionType::VaultDeposit, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("5000000")), + }; + + let json_str = serde_json::to_string(&vault_deposit).unwrap(); + let deserialized: VaultDeposit = serde_json::from_str(&json_str).unwrap(); + assert_eq!(vault_deposit, deserialized); +} + +#[test] +fn test_vault_deposit_serde_roundtrip_issued() { + let vault_deposit = VaultDeposit { + common_fields: CommonFields { + account: "rDepositorICA".into(), + transaction_type: TransactionType::VaultDeposit, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( + "USD".into(), + "rIssuer789".into(), + "1000".into(), + )), + }; + + let json_str = serde_json::to_string(&vault_deposit).unwrap(); + let deserialized: VaultDeposit = serde_json::from_str(&json_str).unwrap(); + assert_eq!(vault_deposit, deserialized); + assert!(vault_deposit.validate().is_ok()); +} + +#[test] +fn test_vault_deposit_builder_pattern() { + use xrpl::models::transactions::CommonTransactionBuilder; + + let vault_deposit = VaultDeposit { + common_fields: CommonFields { + account: "rDepBuilder".into(), + transaction_type: TransactionType::VaultDeposit, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + } + .with_fee("12".into()) + .with_sequence(400) + .with_memo(Memo { + memo_data: Some("vault deposit".into()), + memo_format: None, + memo_type: Some("text".into()), + }); + + assert_eq!(vault_deposit.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(vault_deposit.common_fields.sequence, Some(400)); + assert!(vault_deposit.validate().is_ok()); +} diff --git a/tests/transactions/vault_set.rs b/tests/transactions/vault_set.rs new file mode 100644 index 00000000..f43b722a --- /dev/null +++ b/tests/transactions/vault_set.rs @@ -0,0 +1,77 @@ +// XLS-65 SingleAssetVault — VaultSet integration test stub +// +// VaultSet requires an XLS-65-enabled rippled node. These tests validate +// that the transaction type can be constructed and serialized correctly. + +use xrpl::models::transactions::vault_set::VaultSet; +use xrpl::models::transactions::{CommonFields, Memo, TransactionType}; +use xrpl::models::Model; + +const VAULT_ID: &str = "A0000000000000000000000000000000000000000000000000000000DEADBEEF"; + +#[test] +fn test_vault_set_serde_roundtrip() { + let vault_set = VaultSet { + common_fields: CommonFields { + account: "rVaultOwner123".into(), + transaction_type: TransactionType::VaultSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + data: Some("48656C6C6F".into()), + assets_maximum: None, + domain_id: None, + }; + + let json_str = serde_json::to_string(&vault_set).unwrap(); + let deserialized: VaultSet = serde_json::from_str(&json_str).unwrap(); + assert_eq!(vault_set, deserialized); + assert!(vault_set.validate().is_ok()); +} + +#[test] +fn test_vault_set_all_optional_fields() { + let vault_set = VaultSet { + common_fields: CommonFields { + account: "rVaultOwnerFull".into(), + transaction_type: TransactionType::VaultSet, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + data: Some("48656C6C6F".into()), + assets_maximum: Some("2000000000".into()), + domain_id: Some("D0000000000000000000000000000000000000000000000000000000DEADBEEF".into()), + }; + + let json_str = serde_json::to_string(&vault_set).unwrap(); + let deserialized: VaultSet = serde_json::from_str(&json_str).unwrap(); + assert_eq!(vault_set, deserialized); +} + +#[test] +fn test_vault_set_builder_pattern() { + use xrpl::models::transactions::CommonTransactionBuilder; + + let vault_set = VaultSet { + common_fields: CommonFields { + account: "rSetBuilder".into(), + transaction_type: TransactionType::VaultSet, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + ..Default::default() + } + .with_fee("12".into()) + .with_sequence(700) + .with_memo(Memo { + memo_data: Some("updating vault".into()), + memo_format: None, + memo_type: Some("text".into()), + }); + + assert_eq!(vault_set.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(vault_set.common_fields.sequence, Some(700)); + assert!(vault_set.validate().is_ok()); +} diff --git a/tests/transactions/vault_withdraw.rs b/tests/transactions/vault_withdraw.rs new file mode 100644 index 00000000..fee06aee --- /dev/null +++ b/tests/transactions/vault_withdraw.rs @@ -0,0 +1,77 @@ +// XLS-65 SingleAssetVault — VaultWithdraw integration test stub +// +// VaultWithdraw requires an XLS-65-enabled rippled node. These tests validate +// that the transaction type can be constructed and serialized correctly. + +use xrpl::models::transactions::vault_withdraw::VaultWithdraw; +use xrpl::models::transactions::{CommonFields, Memo, TransactionType}; +use xrpl::models::{Amount, IssuedCurrencyAmount, Model, XRPAmount}; + +const VAULT_ID: &str = "A0000000000000000000000000000000000000000000000000000000DEADBEEF"; + +#[test] +fn test_vault_withdraw_serde_roundtrip_xrp() { + let vault_withdraw = VaultWithdraw { + common_fields: CommonFields { + account: "rWithdrawer123".into(), + transaction_type: TransactionType::VaultWithdraw, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("5000000")), + }; + + let json_str = serde_json::to_string(&vault_withdraw).unwrap(); + let deserialized: VaultWithdraw = serde_json::from_str(&json_str).unwrap(); + assert_eq!(vault_withdraw, deserialized); +} + +#[test] +fn test_vault_withdraw_serde_roundtrip_issued() { + let vault_withdraw = VaultWithdraw { + common_fields: CommonFields { + account: "rWithdrawICA".into(), + transaction_type: TransactionType::VaultWithdraw, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( + "USD".into(), + "rIssuer789".into(), + "500".into(), + )), + }; + + let json_str = serde_json::to_string(&vault_withdraw).unwrap(); + let deserialized: VaultWithdraw = serde_json::from_str(&json_str).unwrap(); + assert_eq!(vault_withdraw, deserialized); + assert!(vault_withdraw.validate().is_ok()); +} + +#[test] +fn test_vault_withdraw_builder_pattern() { + use xrpl::models::transactions::CommonTransactionBuilder; + + let vault_withdraw = VaultWithdraw { + common_fields: CommonFields { + account: "rWdBuilder".into(), + transaction_type: TransactionType::VaultWithdraw, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + } + .with_fee("12".into()) + .with_sequence(500) + .with_memo(Memo { + memo_data: Some("vault withdrawal".into()), + memo_format: None, + memo_type: Some("text".into()), + }); + + assert_eq!(vault_withdraw.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(vault_withdraw.common_fields.sequence, Some(500)); + assert!(vault_withdraw.validate().is_ok()); +} From 81a4b7eeb1cb9b5daaec3b1c648bcd556bb874a0 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Thu, 2 Apr 2026 06:53:41 +0000 Subject: [PATCH 4/7] fix: align XLS-65 vault types with spec - vault_create: add missing Scale (UInt8) field - vault_withdraw: add missing Destination and DestinationTag fields - vault_clawback: change Amount from required Amount<'a> to Option> (NUMBER type per spec) - vault ledger: add missing Owner, LossUnrealized, ShareMPTID, WithdrawalPolicy, Scale, Sequence fields --- src/models/ledger/objects/vault.rs | 133 +++++++++++++--------- src/models/transactions/vault_clawback.rs | 103 ++++++++++------- src/models/transactions/vault_create.rs | 38 ++++++- src/models/transactions/vault_withdraw.rs | 76 +++++++++++++ 4 files changed, 249 insertions(+), 101 deletions(-) diff --git a/src/models/ledger/objects/vault.rs b/src/models/ledger/objects/vault.rs index 79c0cb47..17c23b82 100644 --- a/src/models/ledger/objects/vault.rs +++ b/src/models/ledger/objects/vault.rs @@ -1,5 +1,5 @@ use crate::models::ledger::objects::LedgerEntryType; -use crate::models::{Amount, Currency, FlagCollection, Model, NoFlags}; +use crate::models::{Currency, FlagCollection, Model, NoFlags}; use alloc::borrow::Cow; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; @@ -11,7 +11,7 @@ use super::{CommonFields, LedgerObject}; /// A vault holds a single asset type and issues share tokens (MPTokens) /// to depositors proportional to their ownership of the vault's assets. /// -/// `` +/// `` #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] #[serde(rename_all = "PascalCase")] @@ -22,29 +22,32 @@ pub struct Vault<'a> { /// `` #[serde(flatten)] pub common_fields: CommonFields<'a, NoFlags>, - /// The account that created and owns this vault. + /// The account address of the Vault Owner. + pub owner: Cow<'a, str>, + /// The address of the Vault's pseudo-account. pub account: Cow<'a, str>, - /// The asset that this vault holds. + /// The asset of the vault (XRP, IOU or MPT). pub asset: Currency<'a>, - /// The total amount of assets currently held in the vault. + /// The total value of the vault. pub assets_total: Option>, - /// The amount of assets available for withdrawal. + /// The asset amount that is available in the vault. pub assets_available: Option>, - /// The maximum amount of assets the vault can hold. + /// The maximum asset amount that can be held in the vault. Zero means no cap. pub assets_maximum: Option>, - /// The liquidity provider token balance for this vault. - #[serde(rename = "LPToken")] - pub lp_token: Option>, - /// The share token for this vault. - pub share: Option>, - /// Arbitrary hex-encoded data associated with the vault. + /// The potential loss amount that is not yet realized, expressed as the vault's asset. + pub loss_unrealized: Option>, + /// The identifier of the share MPTokenIssuance object. + #[serde(rename = "ShareMPTID")] + pub share_mpt_id: Option>, + /// Indicates the withdrawal strategy used by the Vault. + pub withdrawal_policy: Option, + /// The Scale specifies the power of 10 to multiply an asset's value by + /// when converting it into an integer-based number of shares. + pub scale: Option, + /// The transaction sequence number that created the vault. + pub sequence: Option, + /// Arbitrary metadata about the Vault. Limited to 256 bytes. pub data: Option>, - /// The ID of the MPToken issuance associated with this vault. - #[serde(rename = "MPTokenIssuanceID")] - pub mpt_issuance_id: Option>, - /// The domain ID associated with the vault. - #[serde(rename = "DomainID")] - pub domain_id: Option>, /// A hint indicating which page of the owner's directory links to this object. pub owner_node: Option>, /// The identifying hash of the transaction that most recently modified this object. @@ -67,16 +70,18 @@ impl<'a> Vault<'a> { pub fn new( index: Option>, ledger_index: Option>, + owner: Cow<'a, str>, account: Cow<'a, str>, asset: Currency<'a>, assets_total: Option>, assets_available: Option>, assets_maximum: Option>, - lp_token: Option>, - share: Option>, + loss_unrealized: Option>, + share_mpt_id: Option>, + withdrawal_policy: Option, + scale: Option, + sequence: Option, data: Option>, - mpt_issuance_id: Option>, - domain_id: Option>, owner_node: Option>, previous_txn_id: Cow<'a, str>, previous_txn_lgr_seq: u32, @@ -88,16 +93,18 @@ impl<'a> Vault<'a> { index, ledger_index, }, + owner, account, asset, assets_total, assets_available, assets_maximum, - lp_token, - share, + loss_unrealized, + share_mpt_id, + withdrawal_policy, + scale, + sequence, data, - mpt_issuance_id, - domain_id, owner_node, previous_txn_id, previous_txn_lgr_seq, @@ -107,8 +114,7 @@ impl<'a> Vault<'a> { #[cfg(test)] mod test_serde { - use crate::models::amount::IssuedCurrencyAmount; - use crate::models::currency::{Currency, IssuedCurrency}; + use crate::models::currency::{Currency, IssuedCurrency, XRP}; use crate::models::ledger::objects::vault::Vault; use alloc::borrow::Cow; @@ -118,21 +124,17 @@ mod test_serde { Some(Cow::from("ForTest")), None, Cow::from("rVaultOwner123"), + Cow::from("rPseudoAccount456"), Currency::IssuedCurrency(IssuedCurrency::new("USD".into(), "rIssuer456".into())), Some("1000000".into()), Some("800000".into()), Some("5000000".into()), - None, - Some(crate::models::Amount::IssuedCurrencyAmount( - IssuedCurrencyAmount::new( - "039C99CD9AB0B70B32ECDA51EAAE471625608EA2".into(), - "rVaultOwner123".into(), - "1000".into(), - ), - )), + Some("0".into()), + Some("00000001C752C42A1EBD6BF2403134F7CFD2F1D835AFD26E".into()), + Some(1), + Some(6), + Some(5), Some("48656C6C6F".into()), - Some("0000000000000001".into()), - None, Some("0".into()), Cow::from("ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890"), 12345678, @@ -148,7 +150,8 @@ mod test_serde { let vault = Vault::new( Some(Cow::from("MinimalTest")), None, - Cow::from("rMinimalVault789"), + Cow::from("rMinimalOwner789"), + Cow::from("rMinimalPseudo789"), Currency::IssuedCurrency(IssuedCurrency::new("EUR".into(), "rEURIssuer012".into())), None, None, @@ -159,6 +162,7 @@ mod test_serde { None, None, None, + None, Cow::from("1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF"), 1, ); @@ -174,27 +178,17 @@ mod test_serde { Some(Cow::from("FullVaultTest")), Some(Cow::from("ledger_idx_123")), Cow::from("rFullVaultOwner456"), + Cow::from("rFullPseudoAccount"), Currency::IssuedCurrency(IssuedCurrency::new("BTC".into(), "rBTCIssuer789".into())), Some("50000000".into()), Some("45000000".into()), Some("100000000".into()), - Some(crate::models::Amount::IssuedCurrencyAmount( - IssuedCurrencyAmount::new( - "LP_TOKEN_CURRENCY".into(), - "rFullVaultOwner456".into(), - "5000".into(), - ), - )), - Some(crate::models::Amount::IssuedCurrencyAmount( - IssuedCurrencyAmount::new( - "SHARE_CURRENCY".into(), - "rFullVaultOwner456".into(), - "2500".into(), - ), - )), + Some("200000".into()), + Some("0000000000000001".into()), + Some(1), + Some(6), + Some(1), Some("44617461".into()), - Some("00000000DEADBEEF".into()), - Some("D0000000000000000000000000000000000000000000000000000000DEADBEEF".into()), Some("42".into()), Cow::from("FEDCBA0987654321FEDCBA0987654321FEDCBA0987654321FEDCBA0987654321"), 99999999, @@ -204,4 +198,31 @@ mod test_serde { let deserialized: Vault = serde_json::from_str(&serialized).unwrap(); assert_eq!(vault, deserialized); } + + #[test] + fn test_xrp_vault() { + let vault = Vault::new( + Some(Cow::from("XRPVaultTest")), + None, + Cow::from("rwhaYGnJMexktjhxAKzRwoCcQ2g6hvBDWu"), + Cow::from("rBVxExjRR6oDMWCeQYgJP7q4JBLGeLBPyv"), + Currency::XRP(XRP::new()), + Some("0".into()), + Some("0".into()), + None, + Some("0".into()), + Some("00000001732B0822A31109C996BCDD7E64E05D446E7998EE".into()), + Some(1), + Some(0), + Some(4), + None, + Some("0".into()), + Cow::from("25C3C8BF2C9EE60DFCDA02F3919D0C4D6BF2D0A4AC9354EFDA438F2ECDDA65E4"), + 5, + ); + + let serialized = serde_json::to_string(&vault).unwrap(); + let deserialized: Vault = serde_json::from_str(&serialized).unwrap(); + assert_eq!(vault, deserialized); + } } diff --git a/src/models/transactions/vault_clawback.rs b/src/models/transactions/vault_clawback.rs index ec7b566d..675d2aba 100644 --- a/src/models/transactions/vault_clawback.rs +++ b/src/models/transactions/vault_clawback.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use crate::models::amount::XRPAmount; -use crate::models::{Amount, FlagCollection, Model, NoFlags, ValidateCurrencies, XRPLModelResult}; +use crate::models::{FlagCollection, Model, NoFlags, XRPLModelResult}; use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType}; @@ -16,16 +16,7 @@ use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, T /// See VaultClawback transaction: /// `` #[skip_serializing_none] -#[derive( - Debug, - Default, - Serialize, - Deserialize, - PartialEq, - Eq, - Clone, - xrpl_rust_macros::ValidateCurrencies, -)] +#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq, Clone)] #[serde(rename_all = "PascalCase")] pub struct VaultClawback<'a> { /// The base fields for all transaction models. @@ -39,13 +30,14 @@ pub struct VaultClawback<'a> { pub vault_id: Cow<'a, str>, /// The account address of the holder whose assets are being clawed back. pub holder: Cow<'a, str>, - /// The amount of the asset to claw back from the holder. - pub amount: Amount<'a>, + /// The asset amount to clawback as a string-encoded number. + /// When 0 or omitted, clawback all funds up to the total shares the Holder owns. + pub amount: Option>, } impl Model for VaultClawback<'_> { fn get_errors(&self) -> XRPLModelResult<()> { - self.validate_currencies() + Ok(()) } } @@ -86,7 +78,7 @@ impl<'a> VaultClawback<'a> { ticket_sequence: Option, vault_id: Cow<'a, str>, holder: Cow<'a, str>, - amount: Amount<'a>, + amount: Option>, ) -> VaultClawback<'a> { VaultClawback { common_fields: CommonFields::new( @@ -115,7 +107,6 @@ impl<'a> VaultClawback<'a> { #[cfg(test)] mod tests { use super::*; - use crate::models::{IssuedCurrencyAmount, XRPAmount}; const VAULT_ID: &str = "A0000000000000000000000000000000000000000000000000000000DEADBEEF"; @@ -130,14 +121,10 @@ mod tests { }, vault_id: VAULT_ID.into(), holder: "rHolder456".into(), - amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( - "USD".into(), - "rIssuer123".into(), - "500".into(), - )), + amount: Some("500".into()), }; - let json_str = r#"{"Account":"rIssuer123","TransactionType":"VaultClawback","Flags":0,"SigningPubKey":"","VaultID":"A0000000000000000000000000000000000000000000000000000000DEADBEEF","Holder":"rHolder456","Amount":{"currency":"USD","issuer":"rIssuer123","value":"500"}}"#; + let json_str = r#"{"Account":"rIssuer123","TransactionType":"VaultClawback","Flags":0,"SigningPubKey":"","VaultID":"A0000000000000000000000000000000000000000000000000000000DEADBEEF","Holder":"rHolder456","Amount":"500"}"#; // Serialize let serialized = serde_json::to_string(&vault_clawback).unwrap(); @@ -152,17 +139,17 @@ mod tests { } #[test] - fn test_serde_xrp_amount() { + fn test_serde_no_amount() { let vault_clawback = VaultClawback { common_fields: CommonFields { - account: "rIssuerXRP789".into(), + account: "rIssuerNoAmt789".into(), transaction_type: TransactionType::VaultClawback, signing_pub_key: Some("".into()), ..Default::default() }, vault_id: VAULT_ID.into(), - holder: "rHolderXRP012".into(), - amount: Amount::XRPAmount(XRPAmount::from("1000000")), + holder: "rHolderNoAmt012".into(), + amount: None, }; let serialized = serde_json::to_string(&vault_clawback).unwrap(); @@ -180,11 +167,7 @@ mod tests { }, vault_id: VAULT_ID.into(), holder: "rHolder456".into(), - amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( - "USD".into(), - "rIssuer123".into(), - "500".into(), - )), + amount: Some("500".into()), } .with_fee("12".into()) .with_sequence(100) @@ -221,7 +204,7 @@ mod tests { }, vault_id: VAULT_ID.into(), holder: "rHolder012".into(), - amount: Amount::XRPAmount(XRPAmount::from("100000")), + amount: Some("100000".into()), }; assert_eq!(vault_clawback.common_fields.account, "rIssuer789"); @@ -245,7 +228,7 @@ mod tests { }, vault_id: VAULT_ID.into(), holder: "rTicketHolder222".into(), - amount: Amount::XRPAmount(XRPAmount::from("2000000")), + amount: Some("2000000".into()), } .with_ticket_sequence(54321) .with_fee("12".into()); @@ -264,11 +247,7 @@ mod tests { }, vault_id: VAULT_ID.into(), holder: "rMultiMemoHolder444".into(), - amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( - "EUR".into(), - "rMultiMemoIssuer333".into(), - "1000".into(), - )), + amount: Some("1000".into()), } .with_memo(Memo { memo_data: Some("compliance action".into()), @@ -309,11 +288,7 @@ mod tests { None, VAULT_ID.into(), "rNewHolder666".into(), - Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( - "USD".into(), - "rNewIssuer555".into(), - "750".into(), - )), + Some("750".into()), ); assert_eq!(vault_clawback.common_fields.account, "rNewIssuer555"); @@ -336,11 +311,51 @@ mod tests { }, vault_id: VAULT_ID.into(), holder: "rValidateHolder888".into(), - amount: Amount::XRPAmount(XRPAmount::from("1000000")), + amount: Some("1000000".into()), } .with_fee("12".into()) .with_sequence(300); assert!(vault_clawback.validate().is_ok()); } + + #[test] + fn test_get_transaction_type() { + use crate::models::transactions::Transaction; + let vault_clawback = VaultClawback { + common_fields: CommonFields { + account: "rTxTypeTest".into(), + transaction_type: TransactionType::VaultClawback, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + holder: "rHolder".into(), + amount: None, + }; + assert_eq!( + *vault_clawback.get_transaction_type(), + TransactionType::VaultClawback + ); + } + + #[test] + fn test_clawback_all_no_amount() { + let vault_clawback = VaultClawback::new( + "rIssuerAll999".into(), + None, + Some("12".into()), + None, + None, + Some(200), + None, + None, + None, + VAULT_ID.into(), + "rHolderAll000".into(), + None, + ); + + assert!(vault_clawback.amount.is_none()); + assert!(vault_clawback.validate().is_ok()); + } } diff --git a/src/models/transactions/vault_create.rs b/src/models/transactions/vault_create.rs index ed7534b0..4bd40145 100644 --- a/src/models/transactions/vault_create.rs +++ b/src/models/transactions/vault_create.rs @@ -49,8 +49,12 @@ pub struct VaultCreate<'a> { #[serde(rename = "DomainID")] pub domain_id: Option>, /// The withdrawal policy for the vault. - /// 0 = unrestricted, 1 = stranded (withdrawals require approval). + /// 1 = first-come-first-serve (0x0001). pub withdrawal_policy: Option, + /// The Scale specifies the power of 10 to multiply an asset's value by + /// when converting it into an integer-based number of shares. + /// Fixed at 0 for XRP and MPT. Configurable 0-18 for IOU (default 6). + pub scale: Option, } impl Model for VaultCreate<'_> { @@ -100,6 +104,7 @@ impl<'a> VaultCreate<'a> { mptoken_metadata: Option>, domain_id: Option>, withdrawal_policy: Option, + scale: Option, ) -> VaultCreate<'a> { VaultCreate { common_fields: CommonFields::new( @@ -124,6 +129,7 @@ impl<'a> VaultCreate<'a> { mptoken_metadata, domain_id, withdrawal_policy, + scale, } } @@ -156,6 +162,12 @@ impl<'a> VaultCreate<'a> { self.withdrawal_policy = Some(withdrawal_policy); self } + + /// Set the scale field. + pub fn with_scale(mut self, scale: u8) -> Self { + self.scale = Some(scale); + self + } } #[cfg(test)] @@ -178,6 +190,7 @@ mod tests { mptoken_metadata: None, domain_id: None, withdrawal_policy: None, + scale: None, }; let json_str = r#"{"Account":"rVaultCreator123","TransactionType":"VaultCreate","Flags":0,"SigningPubKey":"","Asset":{"currency":"USD","issuer":"rIssuer456"}}"#; @@ -211,6 +224,7 @@ mod tests { "D0000000000000000000000000000000000000000000000000000000DEADBEEF".into(), ), withdrawal_policy: Some(1), + scale: Some(6), }; let serialized = serde_json::to_string(&vault_create).unwrap(); @@ -238,6 +252,7 @@ mod tests { .with_mptoken_metadata("ABCDEF".into()) .with_domain_id("D0000000000000000000000000000000000000000000000000000000DEADBEEF".into()) .with_withdrawal_policy(1) + .with_scale(6) .with_memo(Memo { memo_data: Some("creating vault".into()), memo_format: None, @@ -255,6 +270,7 @@ mod tests { assert_eq!(vault_create.assets_maximum, Some("1000000000".into())); assert_eq!(vault_create.mptoken_metadata, Some("ABCDEF".into())); assert_eq!(vault_create.withdrawal_policy, Some(1)); + assert_eq!(vault_create.scale, Some(6)); assert_eq!(vault_create.common_fields.memos.as_ref().unwrap().len(), 1); } @@ -280,6 +296,7 @@ mod tests { assert!(vault_create.mptoken_metadata.is_none()); assert!(vault_create.domain_id.is_none()); assert!(vault_create.withdrawal_policy.is_none()); + assert!(vault_create.scale.is_none()); assert!(vault_create.common_fields.fee.is_none()); assert!(vault_create.common_fields.sequence.is_none()); } @@ -398,6 +415,7 @@ mod tests { Some("ABCDEF".into()), Some("D0000000000000000000000000000000000000000000000000000000DEADBEEF".into()), Some(1), + Some(6), ); assert_eq!(vault.common_fields.account, "rNewVaultAccount"); @@ -414,6 +432,24 @@ mod tests { assert_eq!(vault.withdrawal_policy, Some(1)); } + #[test] + fn test_get_transaction_type() { + use crate::models::transactions::Transaction; + let vault_create = VaultCreate { + common_fields: CommonFields { + account: "rTxTypeTest".into(), + transaction_type: TransactionType::VaultCreate, + ..Default::default() + }, + asset: Currency::XRP(XRP::new()), + ..Default::default() + }; + assert_eq!( + *vault_create.get_transaction_type(), + TransactionType::VaultCreate + ); + } + #[test] fn test_stranded_withdrawal_policy() { let stranded_vault = VaultCreate { diff --git a/src/models/transactions/vault_withdraw.rs b/src/models/transactions/vault_withdraw.rs index 736da1d6..a71f7c8a 100644 --- a/src/models/transactions/vault_withdraw.rs +++ b/src/models/transactions/vault_withdraw.rs @@ -39,6 +39,10 @@ pub struct VaultWithdraw<'a> { pub vault_id: Cow<'a, str>, /// The amount of the asset to withdraw from the vault. pub amount: Amount<'a>, + /// An account to receive the withdrawn assets. Must be able to receive the asset. + pub destination: Option>, + /// Arbitrary tag identifying the reason for the withdrawal to the destination. + pub destination_tag: Option, } impl Model for VaultWithdraw<'_> { @@ -84,6 +88,8 @@ impl<'a> VaultWithdraw<'a> { ticket_sequence: Option, vault_id: Cow<'a, str>, amount: Amount<'a>, + destination: Option>, + destination_tag: Option, ) -> VaultWithdraw<'a> { VaultWithdraw { common_fields: CommonFields::new( @@ -104,8 +110,22 @@ impl<'a> VaultWithdraw<'a> { ), vault_id, amount, + destination, + destination_tag, } } + + /// Set the destination account. + pub fn with_destination(mut self, destination: Cow<'a, str>) -> Self { + self.destination = Some(destination); + self + } + + /// Set the destination tag. + pub fn with_destination_tag(mut self, destination_tag: u32) -> Self { + self.destination_tag = Some(destination_tag); + self + } } #[cfg(test)] @@ -126,6 +146,8 @@ mod tests { }, vault_id: VAULT_ID.into(), amount: Amount::XRPAmount(XRPAmount::from("1000000")), + destination: None, + destination_tag: None, }; let json_str = r#"{"Account":"rWithdrawer123","TransactionType":"VaultWithdraw","Flags":0,"SigningPubKey":"","VaultID":"A0000000000000000000000000000000000000000000000000000000DEADBEEF","Amount":"1000000"}"#; @@ -157,6 +179,8 @@ mod tests { "rIssuer789".into(), "500".into(), )), + destination: None, + destination_tag: None, }; let serialized = serde_json::to_string(&vault_withdraw).unwrap(); @@ -174,6 +198,8 @@ mod tests { }, vault_id: VAULT_ID.into(), amount: Amount::XRPAmount(XRPAmount::from("1000000")), + destination: None, + destination_tag: None, } .with_fee("12".into()) .with_sequence(100) @@ -209,6 +235,8 @@ mod tests { }, vault_id: VAULT_ID.into(), amount: Amount::XRPAmount(XRPAmount::from("5000000")), + destination: None, + destination_tag: None, }; assert_eq!(vault_withdraw.common_fields.account, "rWithdrawer789"); @@ -231,6 +259,8 @@ mod tests { }, vault_id: VAULT_ID.into(), amount: Amount::XRPAmount(XRPAmount::from("2000000")), + destination: None, + destination_tag: None, } .with_ticket_sequence(54321) .with_fee("12".into()); @@ -253,6 +283,8 @@ mod tests { "rUSDIssuer333".into(), "250".into(), )), + destination: None, + destination_tag: None, } .with_memo(Memo { memo_data: Some("partial withdrawal".into()), @@ -293,6 +325,8 @@ mod tests { None, VAULT_ID.into(), Amount::XRPAmount(XRPAmount::from("10000000")), + None, + None, ); assert_eq!(vault_withdraw.common_fields.account, "rNewWithdrawer444"); @@ -304,6 +338,46 @@ mod tests { assert_eq!(vault_withdraw.vault_id, VAULT_ID); } + #[test] + fn test_with_destination() { + let vault_withdraw = VaultWithdraw { + common_fields: CommonFields { + account: "rWithdrawerDest".into(), + transaction_type: TransactionType::VaultWithdraw, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + destination: None, + destination_tag: None, + } + .with_destination("rDestAccount789".into()) + .with_destination_tag(42); + + assert_eq!(vault_withdraw.destination, Some("rDestAccount789".into())); + assert_eq!(vault_withdraw.destination_tag, Some(42)); + } + + #[test] + fn test_get_transaction_type() { + use crate::models::transactions::Transaction; + let vault_withdraw = VaultWithdraw { + common_fields: CommonFields { + account: "rTxTypeTest".into(), + transaction_type: TransactionType::VaultWithdraw, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + destination: None, + destination_tag: None, + }; + assert_eq!( + *vault_withdraw.get_transaction_type(), + TransactionType::VaultWithdraw + ); + } + #[test] fn test_validate() { let vault_withdraw = VaultWithdraw { @@ -314,6 +388,8 @@ mod tests { }, vault_id: VAULT_ID.into(), amount: Amount::XRPAmount(XRPAmount::from("1000000")), + destination: None, + destination_tag: None, } .with_fee("12".into()) .with_sequence(300); From 8bb88c52513be898e5610814d3fcd21f649597e5 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 3 Apr 2026 04:33:47 +0000 Subject: [PATCH 5/7] fix: correct type mismatches in XLS-65 vault integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VaultClawback.amount is Option>, not Amount — fix test to use string values. Add missing `scale` field to VaultCreate tests and missing `destination`/`destination_tag` fields to VaultWithdraw tests. --- tests/transactions/vault_clawback.rs | 18 +++++------------- tests/transactions/vault_create.rs | 2 ++ tests/transactions/vault_withdraw.rs | 6 ++++++ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/transactions/vault_clawback.rs b/tests/transactions/vault_clawback.rs index 92310071..f9367322 100644 --- a/tests/transactions/vault_clawback.rs +++ b/tests/transactions/vault_clawback.rs @@ -5,7 +5,7 @@ use xrpl::models::transactions::vault_clawback::VaultClawback; use xrpl::models::transactions::{CommonFields, Memo, TransactionType}; -use xrpl::models::{Amount, IssuedCurrencyAmount, Model, XRPAmount}; +use xrpl::models::Model; const VAULT_ID: &str = "A0000000000000000000000000000000000000000000000000000000DEADBEEF"; @@ -20,11 +20,7 @@ fn test_vault_clawback_serde_roundtrip() { }, vault_id: VAULT_ID.into(), holder: "rHolder456".into(), - amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( - "USD".into(), - "rIssuer123".into(), - "500".into(), - )), + amount: Some("500".into()), }; let json_str = serde_json::to_string(&vault_clawback).unwrap(); @@ -34,7 +30,7 @@ fn test_vault_clawback_serde_roundtrip() { } #[test] -fn test_vault_clawback_xrp_amount() { +fn test_vault_clawback_no_amount() { let vault_clawback = VaultClawback { common_fields: CommonFields { account: "rIssuerXRP".into(), @@ -44,7 +40,7 @@ fn test_vault_clawback_xrp_amount() { }, vault_id: VAULT_ID.into(), holder: "rHolderXRP".into(), - amount: Amount::XRPAmount(XRPAmount::from("2000000")), + amount: None, }; let json_str = serde_json::to_string(&vault_clawback).unwrap(); @@ -64,11 +60,7 @@ fn test_vault_clawback_builder_pattern() { }, vault_id: VAULT_ID.into(), holder: "rTarget".into(), - amount: Amount::IssuedCurrencyAmount(IssuedCurrencyAmount::new( - "EUR".into(), - "rClawBuilder".into(), - "1000".into(), - )), + amount: Some("1000".into()), } .with_fee("12".into()) .with_sequence(600) diff --git a/tests/transactions/vault_create.rs b/tests/transactions/vault_create.rs index f52199e7..ff7a7e11 100644 --- a/tests/transactions/vault_create.rs +++ b/tests/transactions/vault_create.rs @@ -23,6 +23,7 @@ fn test_vault_create_serde_roundtrip() { mptoken_metadata: None, domain_id: None, withdrawal_policy: None, + scale: None, }; let json_str = serde_json::to_string(&vault_create).unwrap(); @@ -46,6 +47,7 @@ fn test_vault_create_with_all_optional_fields() { mptoken_metadata: Some("ABCDEF".into()), domain_id: Some("D0000000000000000000000000000000000000000000000000000000DEADBEEF".into()), withdrawal_policy: Some(1), + scale: Some(6), }; assert!(vault_create.validate().is_ok()); diff --git a/tests/transactions/vault_withdraw.rs b/tests/transactions/vault_withdraw.rs index fee06aee..b232081c 100644 --- a/tests/transactions/vault_withdraw.rs +++ b/tests/transactions/vault_withdraw.rs @@ -20,6 +20,8 @@ fn test_vault_withdraw_serde_roundtrip_xrp() { }, vault_id: VAULT_ID.into(), amount: Amount::XRPAmount(XRPAmount::from("5000000")), + destination: None, + destination_tag: None, }; let json_str = serde_json::to_string(&vault_withdraw).unwrap(); @@ -42,6 +44,8 @@ fn test_vault_withdraw_serde_roundtrip_issued() { "rIssuer789".into(), "500".into(), )), + destination: None, + destination_tag: None, }; let json_str = serde_json::to_string(&vault_withdraw).unwrap(); @@ -62,6 +66,8 @@ fn test_vault_withdraw_builder_pattern() { }, vault_id: VAULT_ID.into(), amount: Amount::XRPAmount(XRPAmount::from("1000000")), + destination: None, + destination_tag: None, } .with_fee("12".into()) .with_sequence(500) From 4ddf057788e396ea38fc5490f2c7990fbfcb2cf5 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Mon, 20 Apr 2026 05:50:41 +0000 Subject: [PATCH 6/7] fix(xls-65): address code-review findings on vault transactions Introduces a typed Flags enum for VaultCreate plus hex-blob and VaultID validation across every vault transaction, closing the CRITICAL gaps flagged during review. - Add VaultCreateFlag with TfVaultPrivate (0x00010000) and TfVaultShareNonTransferable (0x00020000) per XLS-65. VaultCreate now uses CommonFields<'_, VaultCreateFlag> so flag bits serialize correctly instead of always emitting 0. - Add vault_common module with validate_vault_id and validate_hex_blob helpers. Reused by VaultSet, VaultDelete, VaultDeposit, VaultWithdraw, and VaultClawback so VaultID (64 hex chars, ASCII hexdigits) is rejected at the client boundary instead of by rippled. - VaultCreate.get_errors enforces XLS-65 blob caps: Data <= 256 bytes (512 hex chars), MPTokenMetadata <= 1024 bytes (2048 hex chars), and rejects non-hex characters. - Vault ledger entry tests now assert raw PascalCase keys (Account, Owner, Asset, AssetsTotal, AssetsAvailable, ShareMPTID, WithdrawalPolicy, PreviousTxnID, PreviousTxnLgrSeq, ...) so silent renames that would break wire compatibility with rippled are caught. - New unit tests: combined-flag bit serialization, oversized/non-hex Data and MPTokenMetadata rejection, invalid VaultID rejection (length and character class), and positive/negative paths for the shared helpers. Gates (taskset -c 1-25 -j 25): cargo fmt check, cargo clippy --all-features -D warnings, cargo test --release --lib all pass; 671 library tests green. --- src/models/ledger/objects/vault.rs | 78 +++++++++ src/models/transactions/mod.rs | 1 + src/models/transactions/vault_clawback.rs | 3 +- src/models/transactions/vault_common.rs | 98 ++++++++++++ src/models/transactions/vault_create.rs | 186 ++++++++++++++++++++-- src/models/transactions/vault_delete.rs | 3 +- src/models/transactions/vault_deposit.rs | 4 +- src/models/transactions/vault_set.rs | 30 +++- src/models/transactions/vault_withdraw.rs | 4 +- 9 files changed, 391 insertions(+), 16 deletions(-) create mode 100644 src/models/transactions/vault_common.rs diff --git a/src/models/ledger/objects/vault.rs b/src/models/ledger/objects/vault.rs index 17c23b82..910a2f48 100644 --- a/src/models/ledger/objects/vault.rs +++ b/src/models/ledger/objects/vault.rs @@ -67,6 +67,7 @@ impl<'a> LedgerObject for Vault<'a> { } impl<'a> Vault<'a> { + #[allow(clippy::too_many_arguments)] pub fn new( index: Option>, ledger_index: Option>, @@ -199,6 +200,83 @@ mod test_serde { assert_eq!(vault, deserialized); } + #[test] + fn test_serialized_keys_are_pascal_case() { + // Assert the raw wire format uses the exact PascalCase keys that + // XRPL expects. Relying only on a struct round-trip would silently + // tolerate a rename that breaks compatibility with rippled. + let vault = Vault::new( + Some(Cow::from("KeysTest")), + None, + Cow::from("rKeysOwner"), + Cow::from("rKeysAccount"), + Currency::IssuedCurrency(IssuedCurrency::new("USD".into(), "rIssuerX".into())), + Some("100".into()), + Some("90".into()), + Some("200".into()), + Some("5".into()), + Some("00000001C752C42A1EBD6BF2403134F7CFD2F1D835AFD26E".into()), + Some(1), + Some(6), + Some(1), + Some("48656C6C6F".into()), + Some("0".into()), + Cow::from("ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890"), + 100, + ); + + let json = serde_json::to_string(&vault).unwrap(); + assert!(json.contains("\"Account\""), "missing Account key: {json}"); + assert!(json.contains("\"Owner\""), "missing Owner key: {json}"); + assert!(json.contains("\"Asset\""), "missing Asset key: {json}"); + assert!( + json.contains("\"AssetsTotal\""), + "missing AssetsTotal key: {json}" + ); + assert!( + json.contains("\"AssetsAvailable\""), + "missing AssetsAvailable key: {json}" + ); + assert!( + json.contains("\"AssetsMaximum\""), + "missing AssetsMaximum key: {json}" + ); + assert!( + json.contains("\"LossUnrealized\""), + "missing LossUnrealized key: {json}" + ); + assert!( + json.contains("\"ShareMPTID\""), + "missing ShareMPTID key: {json}" + ); + assert!( + json.contains("\"WithdrawalPolicy\""), + "missing WithdrawalPolicy key: {json}" + ); + assert!(json.contains("\"Scale\""), "missing Scale key: {json}"); + assert!( + json.contains("\"Sequence\""), + "missing Sequence key: {json}" + ); + assert!(json.contains("\"Data\""), "missing Data key: {json}"); + assert!( + json.contains("\"OwnerNode\""), + "missing OwnerNode key: {json}" + ); + assert!( + json.contains("\"PreviousTxnID\""), + "missing PreviousTxnID key: {json}" + ); + assert!( + json.contains("\"PreviousTxnLgrSeq\""), + "missing PreviousTxnLgrSeq key: {json}" + ); + assert!( + json.contains("\"LedgerEntryType\":\"Vault\""), + "missing LedgerEntryType=Vault: {json}" + ); + } + #[test] fn test_xrp_vault() { let vault = Vault::new( diff --git a/src/models/transactions/mod.rs b/src/models/transactions/mod.rs index 0c1f5f14..6f5f402f 100644 --- a/src/models/transactions/mod.rs +++ b/src/models/transactions/mod.rs @@ -32,6 +32,7 @@ pub mod signer_list_set; pub mod ticket_create; pub mod trust_set; pub mod vault_clawback; +pub(crate) mod vault_common; pub mod vault_create; pub mod vault_delete; pub mod vault_deposit; diff --git a/src/models/transactions/vault_clawback.rs b/src/models/transactions/vault_clawback.rs index 675d2aba..c611aa8d 100644 --- a/src/models/transactions/vault_clawback.rs +++ b/src/models/transactions/vault_clawback.rs @@ -6,6 +6,7 @@ use serde_with::skip_serializing_none; use crate::models::amount::XRPAmount; use crate::models::{FlagCollection, Model, NoFlags, XRPLModelResult}; +use super::vault_common::validate_vault_id; use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType}; /// Claw back assets from a vault holder on the XRP Ledger (XLS-65). @@ -37,7 +38,7 @@ pub struct VaultClawback<'a> { impl Model for VaultClawback<'_> { fn get_errors(&self) -> XRPLModelResult<()> { - Ok(()) + validate_vault_id(&self.vault_id) } } diff --git a/src/models/transactions/vault_common.rs b/src/models/transactions/vault_common.rs new file mode 100644 index 00000000..a25a90b9 --- /dev/null +++ b/src/models/transactions/vault_common.rs @@ -0,0 +1,98 @@ +//! Shared validation helpers for XLS-65 Vault transactions. + +use alloc::string::ToString; + +use crate::models::{XRPLModelException, XRPLModelResult}; + +/// The canonical length, in hex characters, of a VaultID. +/// +/// A VaultID is a 256-bit hash, which is 32 bytes or 64 hex characters +/// when serialized on the wire. +pub(crate) const VAULT_ID_HEX_LEN: usize = 64; + +/// Validate a VaultID value: must be exactly 64 ASCII hex characters. +/// +/// Used by every vault transaction that references an existing vault +/// (VaultSet, VaultDelete, VaultDeposit, VaultWithdraw, VaultClawback). +pub(crate) fn validate_vault_id(vault_id: &str) -> XRPLModelResult<()> { + if vault_id.len() != VAULT_ID_HEX_LEN { + return Err(XRPLModelException::InvalidValueFormat { + field: "vault_id".to_string(), + format: "64 hex characters (256-bit hash)".to_string(), + found: vault_id.to_string(), + }); + } + if !vault_id.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(XRPLModelException::InvalidValueFormat { + field: "vault_id".to_string(), + format: "ASCII hexadecimal".to_string(), + found: vault_id.to_string(), + }); + } + Ok(()) +} + +/// Validate a hex-encoded blob field: must be pure ASCII hex and not exceed +/// `max_hex_chars` characters in length (2 hex chars per byte). +pub(crate) fn validate_hex_blob( + field: &'static str, + value: &str, + max_hex_chars: usize, +) -> XRPLModelResult<()> { + if value.len() > max_hex_chars { + return Err(XRPLModelException::ValueTooLong { + field: field.to_string(), + max: max_hex_chars, + found: value.len(), + }); + } + if !value.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(XRPLModelException::InvalidValueFormat { + field: field.to_string(), + format: "ASCII hexadecimal".to_string(), + found: value.to_string(), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_vault_id_accepts_valid_id() { + let id = "A0000000000000000000000000000000000000000000000000000000DEADBEEF"; + assert!(validate_vault_id(id).is_ok()); + } + + #[test] + fn test_validate_vault_id_rejects_wrong_length() { + assert!(validate_vault_id("DEADBEEF").is_err()); + let too_long = "A".repeat(65); + assert!(validate_vault_id(&too_long).is_err()); + } + + #[test] + fn test_validate_vault_id_rejects_non_hex() { + let id = "Z0000000000000000000000000000000000000000000000000000000DEADBEEF"; + assert!(validate_vault_id(id).is_err()); + } + + #[test] + fn test_validate_hex_blob_accepts_valid() { + assert!(validate_hex_blob("data", "48656C6C6F", 512).is_ok()); + assert!(validate_hex_blob("data", "", 512).is_ok()); + } + + #[test] + fn test_validate_hex_blob_rejects_too_long() { + let long = "A".repeat(513); + assert!(validate_hex_blob("data", &long, 512).is_err()); + } + + #[test] + fn test_validate_hex_blob_rejects_non_hex() { + assert!(validate_hex_blob("data", "XYZ", 512).is_err()); + } +} diff --git a/src/models/transactions/vault_create.rs b/src/models/transactions/vault_create.rs index 4bd40145..0dc47151 100644 --- a/src/models/transactions/vault_create.rs +++ b/src/models/transactions/vault_create.rs @@ -1,15 +1,43 @@ use alloc::borrow::Cow; use alloc::vec::Vec; use serde::{Deserialize, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; use serde_with::skip_serializing_none; +use strum_macros::{AsRefStr, Display, EnumIter}; use crate::models::amount::XRPAmount; -use crate::models::{ - Currency, FlagCollection, Model, NoFlags, ValidateCurrencies, XRPLModelResult, -}; +use crate::models::{Currency, FlagCollection, Model, ValidateCurrencies, XRPLModelResult}; +use super::vault_common::validate_hex_blob; use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType}; +/// Maximum length, in hex characters, of the VaultCreate `Data` field. +/// Per XLS-65, arbitrary metadata is capped at 256 bytes = 512 hex chars. +const MAX_VAULT_DATA_HEX_LEN: usize = 512; + +/// Maximum length, in hex characters, of the VaultCreate `MPTokenMetadata` +/// field. Per XLS-65, share-token metadata is capped at 1024 bytes +/// = 2048 hex chars. +const MAX_VAULT_MPTOKEN_METADATA_HEX_LEN: usize = 2048; + +/// Transactions of the VaultCreate type support additional values in the +/// Flags field. This enum represents those options. +/// +/// See XLS-65 SingleAssetVault: +/// `` +#[derive( + Debug, Eq, PartialEq, Copy, Clone, Serialize_repr, Deserialize_repr, Display, AsRefStr, EnumIter, +)] +#[repr(u32)] +pub enum VaultCreateFlag { + /// The vault is private: only accounts on the vault's domain allow-list + /// may deposit into it. + TfVaultPrivate = 0x00010000, + /// Share tokens issued by this vault are non-transferable: holders + /// cannot send them to other accounts, only redeem via VaultWithdraw. + TfVaultShareNonTransferable = 0x00020000, +} + /// Create a new single-asset vault on the XRP Ledger (XLS-65). /// /// A vault holds a single asset type and issues share tokens (MPTokens) @@ -35,7 +63,7 @@ pub struct VaultCreate<'a> { /// See Transaction Common Fields: /// `` #[serde(flatten)] - pub common_fields: CommonFields<'a, NoFlags>, + pub common_fields: CommonFields<'a, VaultCreateFlag>, /// The asset that this vault will hold. pub asset: Currency<'a>, /// Arbitrary hex-encoded data associated with the vault. @@ -59,16 +87,31 @@ pub struct VaultCreate<'a> { impl Model for VaultCreate<'_> { fn get_errors(&self) -> XRPLModelResult<()> { - self.validate_currencies() + self.validate_currencies()?; + if let Some(data) = self.data.as_deref() { + validate_hex_blob("data", data, MAX_VAULT_DATA_HEX_LEN)?; + } + if let Some(metadata) = self.mptoken_metadata.as_deref() { + validate_hex_blob( + "mptoken_metadata", + metadata, + MAX_VAULT_MPTOKEN_METADATA_HEX_LEN, + )?; + } + Ok(()) } } -impl<'a> Transaction<'a, NoFlags> for VaultCreate<'a> { - fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { +impl<'a> Transaction<'a, VaultCreateFlag> for VaultCreate<'a> { + fn has_flag(&self, flag: &VaultCreateFlag) -> bool { + self.common_fields.has_flag(flag) + } + + fn get_common_fields(&self) -> &CommonFields<'_, VaultCreateFlag> { &self.common_fields } - fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, VaultCreateFlag> { &mut self.common_fields } @@ -77,8 +120,8 @@ impl<'a> Transaction<'a, NoFlags> for VaultCreate<'a> { } } -impl<'a> CommonTransactionBuilder<'a, NoFlags> for VaultCreate<'a> { - fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { +impl<'a> CommonTransactionBuilder<'a, VaultCreateFlag> for VaultCreate<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, VaultCreateFlag> { &mut self.common_fields } @@ -88,10 +131,12 @@ impl<'a> CommonTransactionBuilder<'a, NoFlags> for VaultCreate<'a> { } impl<'a> VaultCreate<'a> { + #[allow(clippy::too_many_arguments)] pub fn new( account: Cow<'a, str>, account_txn_id: Option>, fee: Option>, + flags: Option>, last_ledger_sequence: Option, memos: Option>, sequence: Option, @@ -112,7 +157,7 @@ impl<'a> VaultCreate<'a> { TransactionType::VaultCreate, account_txn_id, fee, - Some(FlagCollection::default()), + Some(flags.unwrap_or_default()), last_ledger_sequence, memos, None, @@ -168,12 +213,19 @@ impl<'a> VaultCreate<'a> { self.scale = Some(scale); self } + + /// Append a flag to this transaction's flag set. + pub fn with_flag(mut self, flag: VaultCreateFlag) -> Self { + self.common_fields.flags.0.push(flag); + self + } } #[cfg(test)] mod tests { use super::*; use crate::models::currency::{IssuedCurrency, XRP}; + use alloc::string::String; #[test] fn test_serde() { @@ -403,6 +455,7 @@ mod tests { "rNewVaultAccount".into(), None, Some("12".into()), + None, Some(7108682), None, Some(100), @@ -471,4 +524,115 @@ mod tests { assert_eq!(stranded_vault.withdrawal_policy, Some(1)); assert!(stranded_vault.validate().is_ok()); } + + #[test] + fn test_vault_create_flag_values() { + // Raw bit values defined by XLS-65 must not drift. + assert_eq!(VaultCreateFlag::TfVaultPrivate as u32, 0x00010000); + assert_eq!( + VaultCreateFlag::TfVaultShareNonTransferable as u32, + 0x00020000 + ); + } + + #[test] + fn test_vault_create_flags_serialize() { + // With both flags set the serialized Flags field must equal the OR + // of the two bit values (0x00010000 | 0x00020000 = 0x00030000 = 196608). + let vault_create = VaultCreate { + common_fields: CommonFields { + account: "rFlaggedVault".into(), + transaction_type: TransactionType::VaultCreate, + signing_pub_key: Some("".into()), + ..Default::default() + }, + asset: Currency::XRP(XRP::new()), + ..Default::default() + } + .with_flag(VaultCreateFlag::TfVaultPrivate) + .with_flag(VaultCreateFlag::TfVaultShareNonTransferable); + + let serialized = serde_json::to_string(&vault_create).unwrap(); + assert!( + serialized.contains("\"Flags\":196608"), + "expected combined flag bits 0x30000 in serialized output, got: {serialized}" + ); + + // Round-trip through JSON to confirm both flags survive deserialize. + let deserialized: VaultCreate = serde_json::from_str(&serialized).unwrap(); + assert!(deserialized + .common_fields + .flags + .0 + .contains(&VaultCreateFlag::TfVaultPrivate)); + assert!(deserialized + .common_fields + .flags + .0 + .contains(&VaultCreateFlag::TfVaultShareNonTransferable)); + } + + #[test] + fn test_data_too_long_rejected() { + // 513 hex chars (exceeds 512 = 256-byte cap). + let oversize: String = "A".repeat(513); + let vault_create = VaultCreate { + common_fields: CommonFields { + account: "rDataTooLong".into(), + transaction_type: TransactionType::VaultCreate, + ..Default::default() + }, + asset: Currency::XRP(XRP::new()), + data: Some(oversize.into()), + ..Default::default() + }; + assert!(vault_create.validate().is_err()); + } + + #[test] + fn test_data_non_hex_rejected() { + let vault_create = VaultCreate { + common_fields: CommonFields { + account: "rDataBadHex".into(), + transaction_type: TransactionType::VaultCreate, + ..Default::default() + }, + asset: Currency::XRP(XRP::new()), + data: Some("not-hex!".into()), + ..Default::default() + }; + assert!(vault_create.validate().is_err()); + } + + #[test] + fn test_mptoken_metadata_too_long_rejected() { + // 2049 hex chars (exceeds 2048 = 1024-byte cap). + let oversize: String = "B".repeat(2049); + let vault_create = VaultCreate { + common_fields: CommonFields { + account: "rMetaTooLong".into(), + transaction_type: TransactionType::VaultCreate, + ..Default::default() + }, + asset: Currency::XRP(XRP::new()), + mptoken_metadata: Some(oversize.into()), + ..Default::default() + }; + assert!(vault_create.validate().is_err()); + } + + #[test] + fn test_mptoken_metadata_non_hex_rejected() { + let vault_create = VaultCreate { + common_fields: CommonFields { + account: "rMetaBadHex".into(), + transaction_type: TransactionType::VaultCreate, + ..Default::default() + }, + asset: Currency::XRP(XRP::new()), + mptoken_metadata: Some("ZZZZ".into()), + ..Default::default() + }; + assert!(vault_create.validate().is_err()); + } } diff --git a/src/models/transactions/vault_delete.rs b/src/models/transactions/vault_delete.rs index 6a07f3d5..4a24c023 100644 --- a/src/models/transactions/vault_delete.rs +++ b/src/models/transactions/vault_delete.rs @@ -6,6 +6,7 @@ use serde_with::skip_serializing_none; use crate::models::amount::XRPAmount; use crate::models::{FlagCollection, Model, NoFlags, XRPLModelResult}; +use super::vault_common::validate_vault_id; use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType}; /// Delete a vault from the XRP Ledger (XLS-65). @@ -32,7 +33,7 @@ pub struct VaultDelete<'a> { impl Model for VaultDelete<'_> { fn get_errors(&self) -> XRPLModelResult<()> { - Ok(()) + validate_vault_id(&self.vault_id) } } diff --git a/src/models/transactions/vault_deposit.rs b/src/models/transactions/vault_deposit.rs index eda0e494..62af9f59 100644 --- a/src/models/transactions/vault_deposit.rs +++ b/src/models/transactions/vault_deposit.rs @@ -6,6 +6,7 @@ use serde_with::skip_serializing_none; use crate::models::amount::XRPAmount; use crate::models::{Amount, FlagCollection, Model, NoFlags, ValidateCurrencies, XRPLModelResult}; +use super::vault_common::validate_vault_id; use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType}; /// Deposit assets into a vault on the XRP Ledger (XLS-65). @@ -43,7 +44,8 @@ pub struct VaultDeposit<'a> { impl Model for VaultDeposit<'_> { fn get_errors(&self) -> XRPLModelResult<()> { - self.validate_currencies() + self.validate_currencies()?; + validate_vault_id(&self.vault_id) } } diff --git a/src/models/transactions/vault_set.rs b/src/models/transactions/vault_set.rs index 59d3f4b2..b9185d8e 100644 --- a/src/models/transactions/vault_set.rs +++ b/src/models/transactions/vault_set.rs @@ -6,6 +6,7 @@ use serde_with::skip_serializing_none; use crate::models::amount::XRPAmount; use crate::models::{FlagCollection, Model, NoFlags, XRPLModelResult}; +use super::vault_common::validate_vault_id; use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType}; /// Update the settings of an existing vault on the XRP Ledger (XLS-65). @@ -39,7 +40,7 @@ pub struct VaultSet<'a> { impl Model for VaultSet<'_> { fn get_errors(&self) -> XRPLModelResult<()> { - Ok(()) + validate_vault_id(&self.vault_id) } } @@ -337,6 +338,33 @@ mod tests { assert!(vault_set.validate().is_ok()); } + #[test] + fn test_invalid_vault_id_rejected() { + // Wrong length. + let short_id = VaultSet { + common_fields: CommonFields { + account: "rShortID".into(), + transaction_type: TransactionType::VaultSet, + ..Default::default() + }, + vault_id: "DEADBEEF".into(), + ..Default::default() + }; + assert!(short_id.validate().is_err()); + + // Non-hex character. + let bad_hex = VaultSet { + common_fields: CommonFields { + account: "rBadHex".into(), + transaction_type: TransactionType::VaultSet, + ..Default::default() + }, + vault_id: "Z0000000000000000000000000000000000000000000000000000000DEADBEEF".into(), + ..Default::default() + }; + assert!(bad_hex.validate().is_err()); + } + #[test] fn test_update_data_only() { let vault_set = VaultSet { diff --git a/src/models/transactions/vault_withdraw.rs b/src/models/transactions/vault_withdraw.rs index a71f7c8a..9150837a 100644 --- a/src/models/transactions/vault_withdraw.rs +++ b/src/models/transactions/vault_withdraw.rs @@ -6,6 +6,7 @@ use serde_with::skip_serializing_none; use crate::models::amount::XRPAmount; use crate::models::{Amount, FlagCollection, Model, NoFlags, ValidateCurrencies, XRPLModelResult}; +use super::vault_common::validate_vault_id; use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType}; /// Withdraw assets from a vault on the XRP Ledger (XLS-65). @@ -47,7 +48,8 @@ pub struct VaultWithdraw<'a> { impl Model for VaultWithdraw<'_> { fn get_errors(&self) -> XRPLModelResult<()> { - self.validate_currencies() + self.validate_currencies()?; + validate_vault_id(&self.vault_id) } } From e7862ed69a1479832e9d0eaf5fd92acb54ec539b Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 17 Apr 2026 23:29:50 +0000 Subject: [PATCH 7/7] ci: pin rippled Docker image and harden health check wait The rippleci/rippled:develop image updated after 2026-04-01 and broke integration tests across all PRs (container exits before becoming healthy, causing Connection refused on localhost:5005). Pin to the last known-good digest and replace the simple until loop with a bounded retry that checks container liveness, prints status per attempt, and dumps container logs on failure. --- .github/workflows/integration_test.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 12e3df17..cd26abf8 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -11,7 +11,8 @@ on: name: Integration Test env: - RIPPLED_DOCKER_IMAGE: rippleci/rippled:develop + # Pin to known-good digest; rippleci/rippled:develop broke after 2026-04-01 + RIPPLED_DOCKER_IMAGE: rippleci/rippled:develop@sha256:328175bf14b7b83db9e5e6b50c7458bf828b02b2855453efc038233094aa8d85 jobs: integration_test: @@ -41,10 +42,22 @@ jobs: - name: Wait for rippled to be healthy run: | - until docker inspect --format='{{.State.Health.Status}}' rippled-service | grep -q healthy; do - echo "Waiting for rippled to be ready..." + for i in $(seq 1 30); do + if ! docker ps -q -f name=rippled-service | grep -q .; then + echo "Container exited unexpectedly" + docker logs rippled-service 2>&1 || true + exit 1 + fi + STATUS=$(docker inspect --format='{{.State.Health.Status}}' rippled-service 2>/dev/null || echo "unknown") + echo "Attempt $i/30: $STATUS" + if [ "$STATUS" = "healthy" ]; then + exit 0 + fi sleep 2 done + echo "Timed out waiting for rippled" + docker logs rippled-service 2>&1 || true + exit 1 - uses: dtolnay/rust-toolchain@stable