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 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..910a2f48 --- /dev/null +++ b/src/models/ledger/objects/vault.rs @@ -0,0 +1,306 @@ +use crate::models::ledger::objects::LedgerEntryType; +use crate::models::{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 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 of the vault (XRP, IOU or MPT). + pub asset: Currency<'a>, + /// The total value of the vault. + pub assets_total: Option>, + /// The asset amount that is available in the vault. + pub assets_available: Option>, + /// The maximum asset amount that can be held in the vault. Zero means no cap. + pub assets_maximum: Option>, + /// 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>, + /// 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> { + #[allow(clippy::too_many_arguments)] + 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>, + loss_unrealized: Option>, + share_mpt_id: Option>, + withdrawal_policy: Option, + scale: Option, + sequence: Option, + data: 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, + }, + owner, + account, + asset, + assets_total, + assets_available, + assets_maximum, + loss_unrealized, + share_mpt_id, + withdrawal_policy, + scale, + sequence, + data, + owner_node, + previous_txn_id, + previous_txn_lgr_seq, + } + } +} + +#[cfg(test)] +mod test_serde { + use crate::models::currency::{Currency, IssuedCurrency, XRP}; + 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"), + Cow::from("rPseudoAccount456"), + Currency::IssuedCurrency(IssuedCurrency::new("USD".into(), "rIssuer456".into())), + Some("1000000".into()), + Some("800000".into()), + Some("5000000".into()), + Some("0".into()), + Some("00000001C752C42A1EBD6BF2403134F7CFD2F1D835AFD26E".into()), + Some(1), + Some(6), + Some(5), + Some("48656C6C6F".into()), + 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("rMinimalOwner789"), + Cow::from("rMinimalPseudo789"), + Currency::IssuedCurrency(IssuedCurrency::new("EUR".into(), "rEURIssuer012".into())), + None, + 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"), + Cow::from("rFullPseudoAccount"), + Currency::IssuedCurrency(IssuedCurrency::new("BTC".into(), "rBTCIssuer789".into())), + Some("50000000".into()), + Some("45000000".into()), + Some("100000000".into()), + Some("200000".into()), + Some("0000000000000001".into()), + Some(1), + Some(6), + Some(1), + Some("44617461".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); + } + + #[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( + 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/mod.rs b/src/models/transactions/mod.rs index 167d56c8..6f5f402f 100644 --- a/src/models/transactions/mod.rs +++ b/src/models/transactions/mod.rs @@ -31,6 +31,13 @@ pub mod set_regular_key; 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; +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 +103,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..c611aa8d --- /dev/null +++ b/src/models/transactions/vault_clawback.rs @@ -0,0 +1,362 @@ +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::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). +/// +/// 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)] +#[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 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<()> { + validate_vault_id(&self.vault_id) + } +} + +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: Option>, + ) -> 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::*; + + 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: Some("500".into()), + }; + + 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(); + 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_no_amount() { + let vault_clawback = VaultClawback { + common_fields: CommonFields { + account: "rIssuerNoAmt789".into(), + transaction_type: TransactionType::VaultClawback, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + holder: "rHolderNoAmt012".into(), + amount: None, + }; + + 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: Some("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: Some("100000".into()), + }; + + 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: Some("2000000".into()), + } + .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: Some("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(), + Some("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: 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_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 new file mode 100644 index 00000000..0dc47151 --- /dev/null +++ b/src/models/transactions/vault_create.rs @@ -0,0 +1,638 @@ +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, 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) +/// 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, VaultCreateFlag>, + /// 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. + /// 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<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + 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, 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, VaultCreateFlag> { + &mut self.common_fields + } + + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } +} + +impl<'a> CommonTransactionBuilder<'a, VaultCreateFlag> for VaultCreate<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, VaultCreateFlag> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +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, + 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, + scale: Option, + ) -> VaultCreate<'a> { + VaultCreate { + common_fields: CommonFields::new( + account, + TransactionType::VaultCreate, + account_txn_id, + fee, + Some(flags.unwrap_or_default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + asset, + data, + assets_maximum, + mptoken_metadata, + domain_id, + withdrawal_policy, + scale, + } + } + + /// 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 + } + + /// Set the scale field. + pub fn with_scale(mut self, scale: u8) -> Self { + 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() { + 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, + scale: 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), + scale: Some(6), + }; + + 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_scale(6) + .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.scale, Some(6)); + 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.scale.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()), + None, + 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), + Some(6), + ); + + 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_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 { + 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()); + } + + #[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 new file mode 100644 index 00000000..4a24c023 --- /dev/null +++ b/src/models/transactions/vault_delete.rs @@ -0,0 +1,299 @@ +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::vault_common::validate_vault_id; +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<()> { + validate_vault_id(&self.vault_id) + } +} + +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..62af9f59 --- /dev/null +++ b/src/models/transactions/vault_deposit.rs @@ -0,0 +1,322 @@ +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::vault_common::validate_vault_id; +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()?; + validate_vault_id(&self.vault_id) + } +} + +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..b9185d8e --- /dev/null +++ b/src/models/transactions/vault_set.rs @@ -0,0 +1,388 @@ +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::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). +/// +/// 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<()> { + validate_vault_id(&self.vault_id) + } +} + +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_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 { + 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..9150837a --- /dev/null +++ b/src/models/transactions/vault_withdraw.rs @@ -0,0 +1,401 @@ +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::vault_common::validate_vault_id; +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>, + /// 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<'_> { + fn get_errors(&self) -> XRPLModelResult<()> { + self.validate_currencies()?; + validate_vault_id(&self.vault_id) + } +} + +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>, + destination: Option>, + destination_tag: Option, + ) -> 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, + 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)] +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")), + destination: None, + destination_tag: None, + }; + + 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(), + )), + destination: None, + destination_tag: None, + }; + + 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")), + destination: None, + destination_tag: None, + } + .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")), + destination: None, + destination_tag: None, + }; + + 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")), + destination: None, + destination_tag: None, + } + .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(), + )), + destination: None, + destination_tag: None, + } + .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")), + None, + None, + ); + + 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_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 { + common_fields: CommonFields { + account: "rValidateWithdrawer555".into(), + transaction_type: TransactionType::VaultWithdraw, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + destination: None, + destination_tag: None, + } + .with_fee("12".into()) + .with_sequence(300); + + assert!(vault_withdraw.validate().is_ok()); + } +} 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..f9367322 --- /dev/null +++ b/tests/transactions/vault_clawback.rs @@ -0,0 +1,76 @@ +// 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::Model; + +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: Some("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_no_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: None, + }; + + 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: Some("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..ff7a7e11 --- /dev/null +++ b/tests/transactions/vault_create.rs @@ -0,0 +1,83 @@ +// 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, + scale: 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), + scale: Some(6), + }; + + 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..b232081c --- /dev/null +++ b/tests/transactions/vault_withdraw.rs @@ -0,0 +1,83 @@ +// 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")), + destination: None, + destination_tag: None, + }; + + 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(), + )), + destination: None, + destination_tag: None, + }; + + 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")), + destination: None, + destination_tag: None, + } + .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()); +}