diff --git a/src/models/ledger/objects/mod.rs b/src/models/ledger/objects/mod.rs index 45fa941f..3305ca64 100644 --- a/src/models/ledger/objects/mod.rs +++ b/src/models/ledger/objects/mod.rs @@ -13,6 +13,7 @@ pub mod nftoken_offer; pub mod nftoken_page; pub mod offer; pub mod pay_channel; +pub mod permissioned_domain; pub mod ripple_state; pub mod signer_list; pub mod ticket; @@ -35,6 +36,7 @@ use nftoken_offer::NFTokenOffer; use nftoken_page::NFTokenPage; use offer::Offer; use pay_channel::PayChannel; +use permissioned_domain::PermissionedDomain; use ripple_state::RippleState; use signer_list::SignerList; use strum::IntoEnumIterator; @@ -67,6 +69,7 @@ pub enum LedgerEntryType { NFTokenPage = 0x0050, Offer = 0x006F, PayChannel = 0x0078, + PermissionedDomain = 0x0082, RippleState = 0x0072, SignerList = 0x0053, Ticket = 0x0054, @@ -91,6 +94,7 @@ pub enum LedgerEntry<'a> { NFTokenPage(NFTokenPage<'a>), Offer(Offer<'a>), PayChannel(PayChannel<'a>), + PermissionedDomain(PermissionedDomain<'a>), RippleState(RippleState<'a>), SignerList(SignerList<'a>), Ticket(Ticket<'a>), diff --git a/src/models/ledger/objects/permissioned_domain.rs b/src/models/ledger/objects/permissioned_domain.rs new file mode 100644 index 00000000..4e7a9cff --- /dev/null +++ b/src/models/ledger/objects/permissioned_domain.rs @@ -0,0 +1,213 @@ +use crate::models::ledger::objects::LedgerEntryType; +use crate::models::transactions::Credential; +use crate::models::FlagCollection; +use crate::models::Model; +use crate::models::NoFlags; +use alloc::borrow::Cow; +use alloc::vec::Vec; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use super::{CommonFields, LedgerObject}; + +/// The `PermissionedDomain` ledger entry represents a permissioned domain +/// on the XRP Ledger. A permissioned domain defines a set of accepted +/// credentials that restrict access to certain functionality. +/// +/// See XLS-80 PermissionedDomains: +/// `` +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct PermissionedDomain<'a> { + /// The base fields for all ledger object models. + /// + /// See Ledger Object Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The account that owns this permissioned domain. + pub owner: Cow<'a, str>, + /// The list of credentials accepted by this domain. + pub accepted_credentials: Vec, + /// The sequence number of the PermissionedDomainSet transaction that + /// created this domain. + pub sequence: u32, + /// A hint indicating which page of the owner directory links to this object, + /// in case the directory consists of multiple pages. + 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 PermissionedDomain<'a> {} + +impl<'a> LedgerObject for PermissionedDomain<'a> { + fn get_ledger_entry_type(&self) -> LedgerEntryType { + self.common_fields.get_ledger_entry_type() + } +} + +impl<'a> PermissionedDomain<'a> { + pub fn new( + index: Option>, + ledger_index: Option>, + owner: Cow<'a, str>, + accepted_credentials: Vec, + sequence: u32, + 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::PermissionedDomain, + index, + ledger_index, + }, + owner, + accepted_credentials, + sequence, + owner_node, + previous_txn_id, + previous_txn_lgr_seq, + } + } +} + +#[cfg(test)] +mod test_serde { + use super::*; + use alloc::borrow::Cow; + use alloc::string::ToString; + use alloc::vec; + + #[test] + fn test_serialize() { + let domain = PermissionedDomain::new( + Some(Cow::from("ForTest")), + None, + Cow::from("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"), + vec![ + Credential { + issuer: "rIssuerA".to_string(), + credential_type: "KYC".to_string(), + }, + Credential { + issuer: "rIssuerB".to_string(), + credential_type: "AML".to_string(), + }, + ], + 1, + Some(Cow::from("0")), + Cow::from("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2"), + 1000, + ); + + let serialized = serde_json::to_string(&domain).unwrap(); + let deserialized: PermissionedDomain = serde_json::from_str(&serialized).unwrap(); + assert_eq!(domain, deserialized); + } + + #[test] + fn test_serialize_without_owner_node() { + let domain = PermissionedDomain::new( + Some(Cow::from("IndexHash")), + None, + Cow::from("rOwnerAccount123"), + vec![Credential { + issuer: "rIssuer".to_string(), + credential_type: "KYC".to_string(), + }], + 5, + None, + Cow::from("DEADBEEF01234567DEADBEEF01234567DEADBEEF01234567DEADBEEF01234567"), + 500, + ); + + let serialized = serde_json::to_string(&domain).unwrap(); + + // OwnerNode should not appear when None + assert!(!serialized.contains("OwnerNode")); + + let deserialized: PermissionedDomain = serde_json::from_str(&serialized).unwrap(); + assert_eq!(domain, deserialized); + } + + #[test] + fn test_ledger_entry_type() { + let domain = PermissionedDomain::new( + None, + None, + Cow::from("rOwner"), + vec![], + 1, + None, + Cow::from("0000000000000000000000000000000000000000000000000000000000000000"), + 1, + ); + + assert_eq!( + domain.get_ledger_entry_type(), + LedgerEntryType::PermissionedDomain + ); + } + + #[test] + fn test_empty_credentials() { + let domain = PermissionedDomain::new( + None, + None, + Cow::from("rOwner"), + vec![], + 10, + None, + Cow::from("AABB00112233445566778899AABB00112233445566778899AABB001122334455AA"), + 200, + ); + + assert!(domain.accepted_credentials.is_empty()); + + let serialized = serde_json::to_string(&domain).unwrap(); + let deserialized: PermissionedDomain = serde_json::from_str(&serialized).unwrap(); + assert_eq!(domain, deserialized); + } + + #[test] + fn test_fields() { + let domain = PermissionedDomain::new( + Some(Cow::from("TestIndex")), + Some(Cow::from("TestLedgerIndex")), + Cow::from("rOwnerXYZ"), + vec![Credential { + issuer: "rIssuerXYZ".to_string(), + credential_type: "ACCREDITED".to_string(), + }], + 42, + Some(Cow::from("7")), + Cow::from("1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF"), + 999, + ); + + assert_eq!(domain.owner, "rOwnerXYZ"); + assert_eq!(domain.sequence, 42); + assert_eq!(domain.owner_node, Some(Cow::from("7"))); + assert_eq!( + domain.previous_txn_id, + "1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF" + ); + assert_eq!(domain.previous_txn_lgr_seq, 999); + assert_eq!(domain.accepted_credentials.len(), 1); + assert_eq!(domain.common_fields.index, Some(Cow::from("TestIndex"))); + assert_eq!( + domain.common_fields.ledger_index, + Some(Cow::from("TestLedgerIndex")) + ); + } +} diff --git a/src/models/transactions/mod.rs b/src/models/transactions/mod.rs index 167d56c8..ae09cd7b 100644 --- a/src/models/transactions/mod.rs +++ b/src/models/transactions/mod.rs @@ -26,6 +26,8 @@ pub mod payment; pub mod payment_channel_claim; pub mod payment_channel_create; pub mod payment_channel_fund; +pub mod permissioned_domain_delete; +pub mod permissioned_domain_set; pub mod pseudo_transactions; pub mod set_regular_key; pub mod signer_list_set; @@ -90,6 +92,8 @@ pub enum TransactionType { #[default] Payment, PaymentChannelClaim, + PermissionedDomainDelete, + PermissionedDomainSet, PaymentChannelCreate, PaymentChannelFund, SetRegularKey, @@ -572,6 +576,19 @@ pub struct Signer { } } +serde_with_tag! { +/// A credential entry used in PermissionedDomain transactions. +/// Wraps as `{"Credential": {"Issuer": ..., "CredentialType": ...}}` in JSON. +/// +/// See XLS-80 PermissionedDomains: +/// `` +#[derive(Debug, PartialEq, Eq, Clone, Default, new)] +pub struct Credential { + pub issuer: String, + pub credential_type: String, +} +} + /// Standard functions for transactions. pub trait Transaction<'a, T> where diff --git a/src/models/transactions/permissioned_domain_delete.rs b/src/models/transactions/permissioned_domain_delete.rs new file mode 100644 index 00000000..a8cbe8f7 --- /dev/null +++ b/src/models/transactions/permissioned_domain_delete.rs @@ -0,0 +1,313 @@ +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::{ + transactions::{Memo, Signer, Transaction, TransactionType}, + Model, ValidateCurrencies, +}; +use crate::models::{FlagCollection, NoFlags}; + +use super::{CommonFields, CommonTransactionBuilder}; + +/// A PermissionedDomainDelete transaction removes an existing permissioned +/// domain from the XRP Ledger. Only the owner of the domain can delete it. +/// +/// See XLS-80 PermissionedDomains: +/// `` +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct PermissionedDomainDelete<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The ID of the permissioned domain to delete. + #[serde(rename = "DomainID")] + pub domain_id: Cow<'a, str>, +} + +impl<'a> Model for PermissionedDomainDelete<'a> { + fn get_errors(&self) -> crate::models::XRPLModelResult<()> { + self.validate_currencies() + } +} + +impl<'a> Transaction<'a, NoFlags> for PermissionedDomainDelete<'a> { + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } + + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + self.common_fields.get_common_fields() + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + self.common_fields.get_mut_common_fields() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for PermissionedDomainDelete<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> PermissionedDomainDelete<'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, + domain_id: Cow<'a, str>, + ) -> Self { + Self { + common_fields: CommonFields::new( + account, + TransactionType::PermissionedDomainDelete, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + domain_id, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde() { + let txn = PermissionedDomainDelete { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainDelete, + fee: Some("10".into()), + sequence: Some(1), + signing_pub_key: Some("".into()), + ..Default::default() + }, + domain_id: "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2".into(), + }; + + let serialized = serde_json::to_string(&txn).unwrap(); + + // Verify key fields are present + assert!(serialized.contains("PermissionedDomainDelete")); + assert!(serialized.contains("DomainID")); + + let deserialized: PermissionedDomainDelete = serde_json::from_str(&serialized).unwrap(); + assert_eq!(txn, deserialized); + } + + #[test] + fn test_serde_json_format() { + let txn = PermissionedDomainDelete { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainDelete, + fee: Some("12".into()), + sequence: Some(5), + signing_pub_key: Some("".into()), + ..Default::default() + }, + domain_id: "AABB00112233445566778899AABB00112233445566778899AABB001122334455AA".into(), + }; + + let default_json_str = r#"{"Account":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","TransactionType":"PermissionedDomainDelete","Fee":"12","Flags":0,"Sequence":5,"SigningPubKey":"","DomainID":"AABB00112233445566778899AABB00112233445566778899AABB001122334455AA"}"#; + + let default_json_value = serde_json::to_value(default_json_str).unwrap(); + let serialized_string = serde_json::to_string(&txn).unwrap(); + let serialized_value = serde_json::to_value(&serialized_string).unwrap(); + assert_eq!(serialized_value, default_json_value); + + let deserialized: PermissionedDomainDelete = + serde_json::from_str(default_json_str).unwrap(); + assert_eq!(txn, deserialized); + } + + #[test] + fn test_builder_pattern() { + let txn = PermissionedDomainDelete { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainDelete, + ..Default::default() + }, + domain_id: "AABB0011".into(), + } + .with_fee("12".into()) + .with_sequence(100) + .with_last_ledger_sequence(596447) + .with_source_tag(42) + .with_memo(Memo { + memo_data: Some("deleting domain".into()), + memo_format: None, + memo_type: Some("text".into()), + }); + + assert_eq!( + txn.common_fields.account, + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + ); + assert_eq!(txn.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(txn.common_fields.sequence, Some(100)); + assert_eq!(txn.common_fields.last_ledger_sequence, Some(596447)); + assert_eq!(txn.common_fields.source_tag, Some(42)); + assert_eq!(txn.common_fields.memos.as_ref().unwrap().len(), 1); + assert_eq!(txn.domain_id, "AABB0011"); + } + + #[test] + fn test_default() { + let txn = PermissionedDomainDelete { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainDelete, + ..Default::default() + }, + domain_id: "AABB0011".into(), + }; + + assert_eq!( + txn.common_fields.account, + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + ); + assert_eq!( + txn.common_fields.transaction_type, + TransactionType::PermissionedDomainDelete + ); + assert_eq!(txn.domain_id, "AABB0011"); + assert!(txn.common_fields.fee.is_none()); + assert!(txn.common_fields.sequence.is_none()); + } + + #[test] + fn test_new_constructor() { + let txn = PermissionedDomainDelete::new( + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + None, + Some("12".into()), + Some(596447), + None, + Some(1), + None, + None, + None, + "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2".into(), + ); + + assert_eq!( + txn.common_fields.account, + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + ); + assert_eq!( + txn.common_fields.transaction_type, + TransactionType::PermissionedDomainDelete + ); + assert_eq!(txn.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(txn.common_fields.sequence, Some(1)); + assert_eq!(txn.common_fields.last_ledger_sequence, Some(596447)); + assert_eq!( + txn.domain_id, + "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2" + ); + } + + #[test] + fn test_ticket_sequence() { + let txn = PermissionedDomainDelete { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainDelete, + ..Default::default() + }, + domain_id: "AABB0011".into(), + } + .with_ticket_sequence(42) + .with_fee("10".into()); + + assert_eq!(txn.common_fields.ticket_sequence, Some(42)); + assert!(txn.common_fields.sequence.is_none()); + } + + #[test] + fn test_account_txn_id() { + let txn = PermissionedDomainDelete { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainDelete, + ..Default::default() + }, + domain_id: "AABB0011".into(), + } + .with_account_txn_id("F1E2D3C4B5A69788".into()) + .with_fee("10".into()) + .with_sequence(50); + + assert_eq!( + txn.common_fields.account_txn_id, + Some("F1E2D3C4B5A69788".into()) + ); + } + + #[test] + fn test_multiple_memos() { + let txn = PermissionedDomainDelete { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainDelete, + ..Default::default() + }, + domain_id: "AABB0011".into(), + } + .with_memo(Memo { + memo_data: Some("reason 1".into()), + memo_format: None, + memo_type: Some("text".into()), + }) + .with_memo(Memo { + memo_data: Some("reason 2".into()), + memo_format: None, + memo_type: Some("text".into()), + }) + .with_fee("10".into()) + .with_sequence(1); + + assert_eq!(txn.common_fields.memos.as_ref().unwrap().len(), 2); + } +} diff --git a/src/models/transactions/permissioned_domain_set.rs b/src/models/transactions/permissioned_domain_set.rs new file mode 100644 index 00000000..6a08debf --- /dev/null +++ b/src/models/transactions/permissioned_domain_set.rs @@ -0,0 +1,518 @@ +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::exceptions::XRPLModelException; +use crate::models::{ + transactions::{Credential, Memo, Signer, Transaction, TransactionType}, + Model, ValidateCurrencies, +}; +use crate::models::{FlagCollection, NoFlags}; + +use super::{CommonFields, CommonTransactionBuilder}; + +/// A PermissionedDomainSet transaction creates or updates a permissioned +/// domain on the XRP Ledger. A permissioned domain defines a set of +/// accepted credentials that grant access to restricted functionality. +/// +/// When `domain_id` is `None`, a new domain is created. When `domain_id` +/// is provided, the existing domain is updated with the new set of +/// accepted credentials. +/// +/// See XLS-80 PermissionedDomains: +/// `` +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct PermissionedDomainSet<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The ID of an existing permissioned domain to update. If omitted, + /// a new permissioned domain is created. + #[serde(rename = "DomainID")] + pub domain_id: Option>, + /// The list of credentials accepted by this domain. Each credential + /// specifies an issuer and credential type. + pub accepted_credentials: Vec, +} + +impl<'a> Model for PermissionedDomainSet<'a> { + fn get_errors(&self) -> crate::models::XRPLModelResult<()> { + for credential in &self.accepted_credentials { + if credential.issuer.is_empty() { + return Err(XRPLModelException::MissingField("Credential.issuer".into())); + } + if credential.credential_type.is_empty() { + return Err(XRPLModelException::MissingField( + "Credential.credential_type".into(), + )); + } + } + self.validate_currencies() + } +} + +impl<'a> Transaction<'a, NoFlags> for PermissionedDomainSet<'a> { + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } + + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + self.common_fields.get_common_fields() + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + self.common_fields.get_mut_common_fields() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for PermissionedDomainSet<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> PermissionedDomainSet<'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, + domain_id: Option>, + accepted_credentials: Vec, + ) -> Self { + Self { + common_fields: CommonFields::new( + account, + TransactionType::PermissionedDomainSet, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + domain_id, + accepted_credentials, + } + } + + /// Set the domain ID (for updating an existing domain). + pub fn with_domain_id(mut self, domain_id: Cow<'a, str>) -> Self { + self.domain_id = Some(domain_id); + self + } + + /// Set the accepted credentials list. + pub fn with_accepted_credentials(mut self, credentials: Vec) -> Self { + self.accepted_credentials = credentials; + self + } + + /// Add a single credential to the accepted credentials list. + pub fn with_credential(mut self, credential: Credential) -> Self { + self.accepted_credentials.push(credential); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::string::ToString; + use alloc::vec; + + #[test] + fn test_serde() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + fee: Some("10".into()), + sequence: Some(1), + signing_pub_key: Some("".into()), + ..Default::default() + }, + domain_id: None, + accepted_credentials: vec![Credential { + issuer: "rIssuer111111111111111111111".to_string(), + credential_type: "KYC".to_string(), + }], + }; + + let serialized = serde_json::to_string(&txn).unwrap(); + let deserialized: PermissionedDomainSet = serde_json::from_str(&serialized).unwrap(); + assert_eq!(txn, deserialized); + } + + #[test] + fn test_serde_with_domain_id() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + fee: Some("10".into()), + sequence: Some(2), + signing_pub_key: Some("".into()), + ..Default::default() + }, + domain_id: Some( + "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2".into(), + ), + accepted_credentials: vec![Credential { + issuer: "rIssuer222222222222222222222".to_string(), + credential_type: "AML".to_string(), + }], + }; + + let serialized = serde_json::to_string(&txn).unwrap(); + + // Verify DomainID is present in serialized output + assert!(serialized.contains("DomainID")); + assert!( + serialized.contains("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2") + ); + + let deserialized: PermissionedDomainSet = serde_json::from_str(&serialized).unwrap(); + assert_eq!(txn, deserialized); + } + + #[test] + fn test_builder_pattern() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + ..Default::default() + } + .with_fee("12".into()) + .with_sequence(100) + .with_last_ledger_sequence(596447) + .with_source_tag(42) + .with_credential(Credential { + issuer: "rIssuer333333333333333333333".to_string(), + credential_type: "KYC".to_string(), + }); + + assert_eq!( + txn.common_fields.account, + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + ); + assert_eq!(txn.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(txn.common_fields.sequence, Some(100)); + assert_eq!(txn.common_fields.last_ledger_sequence, Some(596447)); + assert_eq!(txn.common_fields.source_tag, Some(42)); + assert_eq!(txn.accepted_credentials.len(), 1); + assert!(txn.domain_id.is_none()); + } + + #[test] + fn test_default() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + ..Default::default() + }; + + assert_eq!( + txn.common_fields.account, + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + ); + assert_eq!( + txn.common_fields.transaction_type, + TransactionType::PermissionedDomainSet + ); + assert!(txn.domain_id.is_none()); + assert!(txn.accepted_credentials.is_empty()); + assert!(txn.common_fields.fee.is_none()); + assert!(txn.common_fields.sequence.is_none()); + } + + #[test] + fn test_with_credentials() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + fee: Some("10".into()), + sequence: Some(5), + ..Default::default() + }, + domain_id: None, + accepted_credentials: vec![ + Credential { + issuer: "rIssuerA".to_string(), + credential_type: "KYC".to_string(), + }, + Credential { + issuer: "rIssuerB".to_string(), + credential_type: "AML".to_string(), + }, + Credential { + issuer: "rIssuerC".to_string(), + credential_type: "ACCREDITED".to_string(), + }, + ], + }; + + assert_eq!(txn.accepted_credentials.len(), 3); + assert_eq!(txn.accepted_credentials[0].issuer, "rIssuerA".to_string()); + assert_eq!( + txn.accepted_credentials[1].credential_type, + "AML".to_string() + ); + assert_eq!( + txn.accepted_credentials[2].credential_type, + "ACCREDITED".to_string() + ); + } + + #[test] + fn test_update_domain() { + let domain_id = + "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2".to_string(); + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + fee: Some("10".into()), + sequence: Some(10), + ..Default::default() + }, + domain_id: Some(domain_id.clone().into()), + accepted_credentials: vec![Credential { + issuer: "rNewIssuer".to_string(), + credential_type: "VERIFIED".to_string(), + }], + }; + + assert_eq!(txn.domain_id, Some(domain_id.into())); + assert_eq!(txn.accepted_credentials.len(), 1); + } + + #[test] + fn test_create_domain() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + fee: Some("10".into()), + sequence: Some(1), + ..Default::default() + }, + domain_id: None, + accepted_credentials: vec![Credential { + issuer: "rIssuer111111111111111111111".to_string(), + credential_type: "KYC".to_string(), + }], + }; + + // Creating a new domain means domain_id is None + assert!(txn.domain_id.is_none()); + assert_eq!(txn.accepted_credentials.len(), 1); + } + + #[test] + fn test_new_constructor() { + let txn = PermissionedDomainSet::new( + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + None, + Some("12".into()), + Some(596447), + None, + Some(1), + None, + None, + None, + None, + vec![Credential { + issuer: "rIssuer".to_string(), + credential_type: "KYC".to_string(), + }], + ); + + assert_eq!( + txn.common_fields.account, + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + ); + assert_eq!( + txn.common_fields.transaction_type, + TransactionType::PermissionedDomainSet + ); + assert_eq!(txn.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(txn.common_fields.sequence, Some(1)); + assert_eq!(txn.common_fields.last_ledger_sequence, Some(596447)); + assert!(txn.domain_id.is_none()); + assert_eq!(txn.accepted_credentials.len(), 1); + } + + #[test] + fn test_with_domain_id_builder() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + ..Default::default() + } + .with_domain_id("AABB0011".into()) + .with_accepted_credentials(vec![Credential { + issuer: "rIssuer".to_string(), + credential_type: "KYC".to_string(), + }]); + + assert_eq!(txn.domain_id, Some("AABB0011".into())); + assert_eq!(txn.accepted_credentials.len(), 1); + } + + #[test] + fn test_with_memo() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + ..Default::default() + } + .with_fee("10".into()) + .with_sequence(1) + .with_memo(Memo { + memo_data: Some("creating domain".into()), + memo_format: None, + memo_type: Some("text".into()), + }) + .with_credential(Credential { + issuer: "rIssuer".to_string(), + credential_type: "KYC".to_string(), + }); + + assert_eq!(txn.common_fields.memos.as_ref().unwrap().len(), 1); + assert_eq!(txn.accepted_credentials.len(), 1); + } + + #[test] + fn test_empty_credentials() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + fee: Some("10".into()), + sequence: Some(1), + ..Default::default() + }, + domain_id: Some("AABB0011".into()), + accepted_credentials: vec![], + }; + + assert!(txn.accepted_credentials.is_empty()); + + // Round-trip serialization with empty credentials + let serialized = serde_json::to_string(&txn).unwrap(); + let deserialized: PermissionedDomainSet = serde_json::from_str(&serialized).unwrap(); + assert_eq!(txn, deserialized); + } + + #[test] + fn test_ticket_sequence() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + ..Default::default() + } + .with_ticket_sequence(42) + .with_fee("10".into()) + .with_credential(Credential { + issuer: "rIssuer".to_string(), + credential_type: "KYC".to_string(), + }); + + assert_eq!(txn.common_fields.ticket_sequence, Some(42)); + assert!(txn.common_fields.sequence.is_none()); + } + + #[test] + fn test_credential_empty_issuer_rejected() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + domain_id: None, + accepted_credentials: vec![Credential { + issuer: "".to_string(), + credential_type: "KYC".to_string(), + }], + }; + + let result = txn.get_errors(); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + XRPLModelException::MissingField("Credential.issuer".into()) + ); + } + + #[test] + fn test_credential_empty_credential_type_rejected() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + domain_id: None, + accepted_credentials: vec![Credential { + issuer: "rIssuer".to_string(), + credential_type: "".to_string(), + }], + }; + + let result = txn.get_errors(); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + XRPLModelException::MissingField("Credential.credential_type".into()) + ); + } +} diff --git a/tests/transactions/mod.rs b/tests/transactions/mod.rs index c0e13b8d..28dcf4a1 100644 --- a/tests/transactions/mod.rs +++ b/tests/transactions/mod.rs @@ -24,6 +24,8 @@ pub mod payment; pub mod payment_channel_claim; pub mod payment_channel_create; pub mod payment_channel_fund; +pub mod permissioned_domain_delete; +pub mod permissioned_domain_set; pub mod set_regular_key; pub mod signer_list_set; pub mod ticket_create; diff --git a/tests/transactions/permissioned_domain_delete.rs b/tests/transactions/permissioned_domain_delete.rs new file mode 100644 index 00000000..1989bae4 --- /dev/null +++ b/tests/transactions/permissioned_domain_delete.rs @@ -0,0 +1,50 @@ +// xrpl.js reference: N/A (XLS-80 is a new feature) +// +// Scenarios: +// - base: submit PermissionedDomainDelete to delete an existing domain +// +// NOTE: PermissionedDomainDelete requires the PermissionedDomains amendment to be enabled +// and a valid domain_id of an existing PermissionedDomain ledger object owned by the account. + +use crate::common::{generate_funded_wallet, get_client, ledger_accept, with_blockchain_lock}; +use xrpl::asynch::transaction::sign_and_submit; +use xrpl::models::transactions::permissioned_domain_delete::PermissionedDomainDelete; + +#[tokio::test] +async fn test_permissioned_domain_delete_base() { + with_blockchain_lock(|| async { + let client = get_client().await; + let wallet = generate_funded_wallet().await; + + // Use a placeholder domain ID -- on a real network this would be an existing domain. + let mut tx = PermissionedDomainDelete::new( + wallet.classic_address.clone().into(), + None, + None, + None, + None, + None, + None, + None, + None, + "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2".into(), + ); + + let result = sign_and_submit(&mut tx, client, &wallet, true, true) + .await + .expect("sign_and_submit should not fail at submission level"); + + // The domain may not exist and the amendment may not be enabled, + // so accept various result codes indicating the transaction was processed. + assert!( + result.engine_result.contains("tesSUCCESS") + || result.engine_result.contains("temDISABLED") + || result.engine_result.contains("tec"), + "Unexpected engine result: {}", + result.engine_result + ); + + ledger_accept().await; + }) + .await; +} diff --git a/tests/transactions/permissioned_domain_set.rs b/tests/transactions/permissioned_domain_set.rs new file mode 100644 index 00000000..73c85dd3 --- /dev/null +++ b/tests/transactions/permissioned_domain_set.rs @@ -0,0 +1,54 @@ +// xrpl.js reference: N/A (XLS-80 is a new feature) +// +// Scenarios: +// - base: submit PermissionedDomainSet to create a new domain +// +// NOTE: PermissionedDomainSet requires the PermissionedDomains amendment to be enabled. +// This test verifies the transaction can be constructed, serialized, and submitted. + +use crate::common::{generate_funded_wallet, get_client, ledger_accept, with_blockchain_lock}; +use xrpl::asynch::transaction::sign_and_submit; +use xrpl::models::transactions::permissioned_domain_set::PermissionedDomainSet; +use xrpl::models::transactions::Credential; + +#[tokio::test] +async fn test_permissioned_domain_set_base() { + with_blockchain_lock(|| async { + let client = get_client().await; + let wallet = generate_funded_wallet().await; + + let mut tx = PermissionedDomainSet::new( + wallet.classic_address.clone().into(), + None, + None, + None, + None, + None, + None, + None, + None, + None, // No domain_id means create new domain + vec![Credential { + issuer: wallet.classic_address.clone(), + credential_type: "KYC".to_string(), + }], + ); + + let result = sign_and_submit(&mut tx, client, &wallet, true, true) + .await + .expect("sign_and_submit should not fail at submission level"); + + // The amendment may not be enabled on the test network, so accept + // various result codes that indicate the transaction was processed. + assert!( + result.engine_result.contains("tesSUCCESS") + || result.engine_result.contains("temDISABLED") + || result.engine_result.contains("tec"), + "Unexpected engine result: {}", + result.engine_result + ); + + ledger_accept().await; + }) + .await; +}