From e22f6a5a1a61e8ef884228974cdf781199a007ac Mon Sep 17 00:00:00 2001 From: e-desouza Date: Tue, 23 Jun 2026 15:07:20 -0400 Subject: [PATCH 01/14] feat(domains): add PermissionedDomain ledger object (XLS-80) Adds LedgerEntryType::PermissionedDomain (0x0082) with Owner, AcceptedCredentials, Sequence, OwnerNode (SoeRequired), PreviousTxnID, PreviousTxnLgrSeq, and Flags fields. OwnerNode typed as non-optional String per rippled SoeRequired constraint. --- src/models/ledger/objects/mod.rs | 4 + .../ledger/objects/permissioned_domain.rs | 178 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/models/ledger/objects/permissioned_domain.rs diff --git a/src/models/ledger/objects/mod.rs b/src/models/ledger/objects/mod.rs index 768deeaf..f82e9697 100644 --- a/src/models/ledger/objects/mod.rs +++ b/src/models/ledger/objects/mod.rs @@ -16,6 +16,7 @@ pub mod nftoken_page; pub mod offer; pub mod oracle; pub mod pay_channel; +pub mod permissioned_domain; pub mod ripple_state; pub mod signer_list; pub mod ticket; @@ -41,6 +42,7 @@ use nftoken_page::NFTokenPage; use offer::Offer; use oracle::Oracle; use pay_channel::PayChannel; +use permissioned_domain::PermissionedDomain; use ripple_state::RippleState; use signer_list::SignerList; use strum::IntoEnumIterator; @@ -76,6 +78,7 @@ pub enum LedgerEntryType { Offer = 0x006F, Oracle = 0x0080, PayChannel = 0x0078, + PermissionedDomain = 0x0082, RippleState = 0x0072, SignerList = 0x0053, Ticket = 0x0054, @@ -103,6 +106,7 @@ pub enum LedgerEntry<'a> { Offer(Offer<'a>), Oracle(Oracle<'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..ba1066ae --- /dev/null +++ b/src/models/ledger/objects/permissioned_domain.rs @@ -0,0 +1,178 @@ +use crate::models::ledger::objects::LedgerEntryType; +use crate::models::transactions::Credential; +use crate::models::FlagCollection; +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: Cow<'a, str>, + /// 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> 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: Cow<'a, str>, + previous_txn_id: Cow<'a, str>, + previous_txn_lgr_seq: u32, + ) -> Self { + Self { + common_fields: CommonFields { + flags: FlagCollection::default(), + ledger_entry_type: LedgerEntryType::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: "4B5943".to_string(), // hex("KYC") + }, + Credential { + issuer: "rIssuerB".to_string(), + credential_type: "414D4C".to_string(), // hex("AML") + }, + ], + 1, + Cow::from("0"), + Cow::from("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2"), + 1000, + ); + + let serialized = serde_json::to_string(&domain).unwrap(); + + // Assert PascalCase JSON keys so silent field renames are caught. + assert!(serialized.contains("\"AcceptedCredentials\"")); + assert!(serialized.contains("\"PreviousTxnID\"")); + assert!(serialized.contains("\"PreviousTxnLgrSeq\"")); + assert!(serialized.contains("\"Owner\"")); + assert!(serialized.contains("\"OwnerNode\"")); + assert!(serialized.contains("\"Sequence\"")); + assert!(serialized.contains("\"LedgerEntryType\"")); + + 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![Credential { + issuer: "rIssuer".to_string(), + credential_type: "4B5943".to_string(), + }], + 1, + Cow::from("0"), + Cow::from("0000000000000000000000000000000000000000000000000000000000000000"), + 1, + ); + + assert_eq!( + domain.get_ledger_entry_type(), + LedgerEntryType::PermissionedDomain + ); + } + + #[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: "41434352454449544544".to_string(), // hex("ACCREDITED") + }], + 42, + Cow::from("7"), + Cow::from("1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF"), + 999, + ); + + assert_eq!(domain.owner, "rOwnerXYZ"); + assert_eq!(domain.sequence, 42); + assert_eq!(domain.owner_node, 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")) + ); + } +} From be46154c97eeddb16592f25bdcf74e3e14a82d52 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Tue, 23 Jun 2026 15:07:26 -0400 Subject: [PATCH 02/14] feat(domains): add PermissionedDomainSet transaction with validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validates: - AcceptedCredentials list must be 1–10 entries (temARRAY_EMPTY / temARRAY_TOO_LARGE) - CredentialType must be even-length hex string, max 128 chars (64 bytes) - Duplicate (Issuer, CredentialType) pairs rejected — comparison is case-normalised to uppercase to match rippled behaviour - Issuer must be a valid classic XRPL address - DomainID (when updating) must not be all zeros --- .../transactions/permissioned_domain_set.rs | 731 ++++++++++++++++++ 1 file changed, 731 insertions(+) create mode 100644 src/models/transactions/permissioned_domain_set.rs diff --git a/src/models/transactions/permissioned_domain_set.rs b/src/models/transactions/permissioned_domain_set.rs new file mode 100644 index 00000000..c16d7825 --- /dev/null +++ b/src/models/transactions/permissioned_domain_set.rs @@ -0,0 +1,731 @@ +use alloc::borrow::Cow; +use alloc::collections::BTreeSet; +use alloc::vec::Vec; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::core::addresscodec::is_valid_classic_address; +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<()> { + // XLS-80 mandates AcceptedCredentials contain between 1 and 10 entries. + if self.accepted_credentials.is_empty() { + return Err(XRPLModelException::MissingField( + "AcceptedCredentials".into(), + )); + } + if self.accepted_credentials.len() > 10 { + return Err(XRPLModelException::ValueTooLong { + field: "AcceptedCredentials".into(), + max: 10, + found: self.accepted_credentials.len(), + }); + } + let mut seen: BTreeSet<(alloc::string::String, alloc::string::String)> = BTreeSet::new(); + for credential in &self.accepted_credentials { + validate_credential(credential)?; + // Normalise CredentialType to uppercase hex before duplicate check so that + // "4b5943" and "4B5943" are treated as the same credential (rippled decodes + // the blob bytes and hashes them; casing is irrelevant at the wire level). + let key = ( + credential.issuer.clone(), + credential.credential_type.to_uppercase(), + ); + if !seen.insert(key) { + return Err(XRPLModelException::InvalidValue { + field: "AcceptedCredentials".into(), + expected: "unique Issuer/CredentialType pairs".into(), + found: credential.credential_type.clone(), + }); + } + } + if let Some(domain_id) = &self.domain_id { + validate_domain_id(domain_id.as_ref())?; + } + self.validate_currencies() + } +} + +/// Validate a `Credential` entry per XLS-80 / rippled `LedgerFormats.cpp`: +/// `Issuer` must be non-empty and `CredentialType` is an `sfBlob` (hex), +/// so it must be non-empty, even-length, hex-only, and at most 128 hex +/// chars (64 bytes, rippled's `MaxCredentialTypeLength`). +pub(crate) fn validate_domain_id(domain_id: &str) -> crate::models::XRPLModelResult<()> { + if domain_id.len() != 64 + || !domain_id.chars().all(|c| c.is_ascii_hexdigit()) + || domain_id.chars().all(|c| c == '0') + { + return Err(XRPLModelException::InvalidValue { + field: "DomainID".into(), + expected: "non-zero 64-character hex string".into(), + found: domain_id.into(), + }); + } + Ok(()) +} + +pub(crate) fn validate_credential(credential: &Credential) -> crate::models::XRPLModelResult<()> { + if credential.issuer.is_empty() { + return Err(XRPLModelException::MissingField("Credential.Issuer".into())); + } + if !is_valid_classic_address(&credential.issuer) { + return Err(XRPLModelException::InvalidValue { + field: "Credential.Issuer".into(), + expected: "valid classic XRPL address".into(), + found: credential.issuer.clone(), + }); + } + let ct = &credential.credential_type; + if ct.is_empty() { + return Err(XRPLModelException::MissingField( + "Credential.CredentialType".into(), + )); + } + if ct.len() > 128 { + return Err(XRPLModelException::ValueTooLong { + field: "Credential.CredentialType".into(), + max: 128, + found: ct.len(), + }); + } + if !ct.len().is_multiple_of(2) || !ct.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(XRPLModelException::InvalidValue { + field: "Credential.CredentialType".into(), + expected: "even-length hex string (<=128 chars)".into(), + found: ct.clone(), + }); + } + Ok(()) +} + +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: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "4B5943".to_string(), // hex("KYC") + }], + }; + + 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: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "414D4C".to_string(), // hex("AML") + }], + }; + + 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: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "4B5943".to_string(), // hex("KYC") + }); + + 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: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "4B5943".to_string(), // hex("KYC") + }, + Credential { + issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "414D4C".to_string(), // hex("AML") + }, + Credential { + issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "41434352454449544544".to_string(), // hex("ACCREDITED") + }, + ], + }; + + assert_eq!(txn.accepted_credentials.len(), 3); + assert_eq!( + txn.accepted_credentials[0].issuer, + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string() + ); + assert_eq!( + txn.accepted_credentials[1].credential_type, + "414D4C".to_string() + ); + assert_eq!( + txn.accepted_credentials[2].credential_type, + "41434352454449544544".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: "5645524946494544".to_string(), // hex("VERIFIED") + }], + }; + + 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: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "4B5943".to_string(), // hex("KYC") + }], + }; + + // 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: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "4B5943".to_string(), // hex("KYC") + }], + ); + + 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: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "4B5943".to_string(), // hex("KYC") + }]); + + 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: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "4B5943".to_string(), // hex("KYC") + }); + + assert_eq!(txn.common_fields.memos.as_ref().unwrap().len(), 1); + assert_eq!(txn.accepted_credentials.len(), 1); + } + + #[test] + fn test_empty_credentials_rejected() { + // XLS-80 mandates AcceptedCredentials has 1..=10 entries; empty must fail validation. + 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![], + }; + + let result = txn.get_errors(); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + XRPLModelException::MissingField("AcceptedCredentials".into()) + ); + } + + #[test] + fn test_too_many_credentials_rejected() { + // XLS-80 caps AcceptedCredentials at 10 entries. + let credentials: Vec = (0..11) + .map(|_| Credential { + issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "4B5943".to_string(), + }) + .collect(); + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + domain_id: None, + accepted_credentials: credentials, + }; + + let result = txn.get_errors(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + XRPLModelException::ValueTooLong { + max: 10, + found: 11, + .. + } + )); + } + + #[test] + fn test_non_hex_credential_type_rejected() { + // CredentialType is an sfBlob; non-hex values must fail validation. + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + domain_id: None, + accepted_credentials: vec![Credential { + issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "KYC".to_string(), // not hex + }], + }; + let result = txn.get_errors(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + XRPLModelException::InvalidValue { .. } + )); + } + + #[test] + fn test_credential_type_64_bytes_accepted() { + // 64 bytes hex-encoded = 128 chars; this is the rippled maximum. + let credential = Credential { + issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "A".repeat(128), + }; + + assert!(validate_credential(&credential).is_ok()); + } + + #[test] + fn test_credential_type_over_64_bytes_rejected() { + let too_long = "A".repeat(130); + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + domain_id: None, + accepted_credentials: vec![Credential { + issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: too_long, + }], + }; + let result = txn.get_errors(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + XRPLModelException::ValueTooLong { max: 128, .. } + )); + } + + #[test] + fn test_duplicate_credentials_rejected() { + let duplicate = Credential { + issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "4B5943".to_string(), + }; + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + domain_id: None, + accepted_credentials: vec![duplicate.clone(), duplicate], + }; + + assert!(matches!( + txn.get_errors(), + Err(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_set_all_zero_domain_id_rejected() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + domain_id: Some("0".repeat(64).into()), + accepted_credentials: vec![Credential { + issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "4B5943".to_string(), + }], + }; + + assert!(matches!( + txn.get_errors(), + Err(XRPLModelException::InvalidValue { .. }) + )); + } + + #[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: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "4B5943".to_string(), // hex("KYC") + }); + + 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: "4B5943".to_string(), // hex("KYC") + }], + }; + + 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: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + credential_type: "".to_string(), + }], + }; + + let result = txn.get_errors(); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + XRPLModelException::MissingField("Credential.CredentialType".into()) + ); + } +} From b45d99faf2fdaa9f50ea50fdfa4bbc4c7aed7e50 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Tue, 23 Jun 2026 15:07:29 -0400 Subject: [PATCH 03/14] feat(domains): add PermissionedDomainDelete transaction with validation Validates DomainID is present and not all zeros. --- .../permissioned_domain_delete.rs | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 src/models/transactions/permissioned_domain_delete.rs diff --git a/src/models/transactions/permissioned_domain_delete.rs b/src/models/transactions/permissioned_domain_delete.rs new file mode 100644 index 00000000..f76a5465 --- /dev/null +++ b/src/models/transactions/permissioned_domain_delete.rs @@ -0,0 +1,368 @@ +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::{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<()> { + // DomainID is the 32-byte hash of the PermissionedDomain ledger entry, + // serialized as 64 uppercase hex chars. + let domain_id = self.domain_id.as_ref(); + if domain_id.len() != 64 + || !domain_id.chars().all(|c| c.is_ascii_hexdigit()) + || domain_id.chars().all(|c| c == '0') + { + return Err(XRPLModelException::InvalidValue { + field: "DomainID".into(), + expected: "non-zero 64-character hex string".into(), + found: domain_id.into(), + }); + } + 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: "AABB00112233445566778899AABB00112233445566778899AABB00112233445A".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, + "AABB00112233445566778899AABB00112233445566778899AABB00112233445A" + ); + } + + #[test] + fn test_default() { + let txn = PermissionedDomainDelete { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainDelete, + ..Default::default() + }, + domain_id: "AABB00112233445566778899AABB00112233445566778899AABB00112233445A".into(), + }; + + assert_eq!( + txn.common_fields.account, + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" + ); + assert_eq!( + txn.common_fields.transaction_type, + TransactionType::PermissionedDomainDelete + ); + assert_eq!( + txn.domain_id, + "AABB00112233445566778899AABB00112233445566778899AABB00112233445A" + ); + 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: "AABB00112233445566778899AABB00112233445566778899AABB00112233445A".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: "AABB00112233445566778899AABB00112233445566778899AABB00112233445A".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_invalid_domain_id_rejected() { + // DomainID must be exactly 64 hex chars (32-byte hash). + let too_short = PermissionedDomainDelete { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainDelete, + ..Default::default() + }, + domain_id: "AABB0011".into(), + }; + assert!(matches!( + too_short.get_errors().unwrap_err(), + XRPLModelException::InvalidValue { .. } + )); + + // Correct length but non-hex chars. + let non_hex = PermissionedDomainDelete { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainDelete, + ..Default::default() + }, + domain_id: "Z".repeat(64).into(), + }; + assert!(matches!( + non_hex.get_errors().unwrap_err(), + XRPLModelException::InvalidValue { .. } + )); + } + + #[test] + fn test_delete_all_zero_domain_id_rejected() { + let txn = PermissionedDomainDelete { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainDelete, + ..Default::default() + }, + domain_id: "0".repeat(64).into(), + }; + + assert!(matches!( + txn.get_errors(), + Err(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_valid_domain_id_accepted() { + let txn = PermissionedDomainDelete { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::PermissionedDomainDelete, + ..Default::default() + }, + domain_id: "A".repeat(64).into(), + }; + assert!(txn.get_errors().is_ok()); + } +} From e2557f0a01b739186f8c5d848e2a104d478a2c1e Mon Sep 17 00:00:00 2001 From: e-desouza Date: Tue, 23 Jun 2026 15:07:34 -0400 Subject: [PATCH 04/14] feat(domains): register PermissionedDomainSet and PermissionedDomainDelete in transaction enum --- src/models/transactions/mod.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/models/transactions/mod.rs b/src/models/transactions/mod.rs index d6ba1d5a..5f3e2a56 100644 --- a/src/models/transactions/mod.rs +++ b/src/models/transactions/mod.rs @@ -33,6 +33,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; @@ -104,6 +106,8 @@ pub enum TransactionType { #[default] Payment, PaymentChannelClaim, + PermissionedDomainDelete, + PermissionedDomainSet, PaymentChannelCreate, PaymentChannelFund, SetRegularKey, @@ -670,6 +674,18 @@ fn validate_oracle_asset_price(value: &Option) -> crate::models::XRPLMod } } +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 From 3a2b1daa3da307d00b87cb2ed00bffd3fa1b92e2 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Tue, 23 Jun 2026 15:07:43 -0400 Subject: [PATCH 05/14] feat(domains): add PermissionedDomain selector to ledger_entry request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PermissionedDomain enum with two lookup variants: - Index(hash) — 64-char hex string validated - Object(account, seq) — account validated as classic address, seq within u32 range XRPLRequest::LedgerEntry variant is boxed to satisfy large_enum_variant lint. Imports ToString and alloc::format for no_std compatibility. --- src/models/requests/ledger_entry.rs | 127 ++++++++++++++++++++++++++-- src/models/requests/mod.rs | 4 +- 2 files changed, 124 insertions(+), 7 deletions(-) diff --git a/src/models/requests/ledger_entry.rs b/src/models/requests/ledger_entry.rs index 708fb033..5131e9f2 100644 --- a/src/models/requests/ledger_entry.rs +++ b/src/models/requests/ledger_entry.rs @@ -1,4 +1,5 @@ use alloc::borrow::Cow; +use alloc::string::ToString; use derive_new::new; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; @@ -41,6 +42,22 @@ pub struct Offer<'a> { pub seq: u64, } +/// Required fields for requesting a PermissionedDomain if not +/// querying by object ID. +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, new)] +pub struct PermissionedDomainObject<'a> { + pub account: Cow<'a, str>, + pub seq: u64, +} + +/// Required fields for requesting a PermissionedDomain by object selector. +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(untagged)] +pub enum PermissionedDomain<'a> { + Index(Cow<'a, str>), + Object(PermissionedDomainObject<'a>), +} + /// Required fields for requesting a Ticket, if not /// querying by object ID. #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, new)] @@ -98,6 +115,7 @@ pub struct LedgerEntry<'a> { pub offer: Option>, pub oracle: Option>, pub payment_channel: Option>, + pub permissioned_domain: Option>, pub ripple_state: Option>, pub ticket: Option>, } @@ -141,11 +159,14 @@ impl<'a> LedgerEntryError for LedgerEntry<'a> { if self.deposit_preauth.is_some() { signing_methods += 1 } + if self.permissioned_domain.is_some() { + signing_methods += 1 + } if self.ticket.is_some() { signing_methods += 1 } if signing_methods != 1 { - Err(XRPLModelException::ExpectedOneOf(&[ + return Err(XRPLModelException::ExpectedOneOf(&[ "index", "account_root", "check", @@ -156,11 +177,40 @@ impl<'a> LedgerEntryError for LedgerEntry<'a> { "escrow", "payment_channel", "deposit_preauth", + "permissioned_domain", "ticket", - ])) - } else { - Ok(()) + ])); } + if let Some(pd) = &self.permissioned_domain { + match pd { + PermissionedDomain::Index(id) => { + if id.len() != 64 || !id.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(XRPLModelException::InvalidValue { + field: "permissioned_domain.index".into(), + expected: "64-character hex string".into(), + found: id.as_ref().into(), + }); + } + } + PermissionedDomain::Object(obj) => { + if !crate::core::addresscodec::is_valid_classic_address(&obj.account) { + return Err(XRPLModelException::InvalidValue { + field: "permissioned_domain.account".into(), + expected: "valid classic XRPL address".into(), + found: obj.account.as_ref().into(), + }); + } + if obj.seq > u32::MAX as u64 { + return Err(XRPLModelException::InvalidValue { + field: "permissioned_domain.seq".into(), + expected: "value within u32 range".into(), + found: obj.seq.to_string(), + }); + } + } + } + } + Ok(()) } } @@ -201,6 +251,7 @@ impl<'a> LedgerEntry<'a> { account_root, check, payment_channel, + permissioned_domain: None, deposit_preauth, directory, escrow, @@ -215,6 +266,35 @@ impl<'a> LedgerEntry<'a> { }), } } + + pub fn new_with_permissioned_domain( + id: Option>, + binary: Option, + permissioned_domain: PermissionedDomain<'a>, + ledger_hash: Option>, + ledger_index: Option>, + ) -> Self { + Self { + permissioned_domain: Some(permissioned_domain), + ..Self::new( + id, + None, + binary, + None, + None, + None, + None, + None, + ledger_hash, + ledger_index, + None, + None, + None, + None, + None, + ) + } + } } pub trait LedgerEntryError { @@ -226,6 +306,7 @@ pub trait LedgerEntryError { mod test_ledger_entry_errors { use super::Offer; use crate::models::Model; + use alloc::format; use alloc::string::ToString; use super::*; @@ -262,11 +343,12 @@ mod test_ledger_entry_errors { "escrow", "payment_channel", "deposit_preauth", + "permissioned_domain", "ticket", ]); assert_eq!( ledger_entry.validate().unwrap_err().to_string().as_str(), - "Expected one of: index, account_root, check, directory, offer, oracle, ripple_state, escrow, payment_channel, deposit_preauth, ticket" + "Expected one of: index, account_root, check, directory, offer, oracle, ripple_state, escrow, payment_channel, deposit_preauth, permissioned_domain, ticket" ); } @@ -298,4 +380,39 @@ mod test_ledger_entry_errors { assert_eq!(req, deserialized); } + + #[test] + fn test_permissioned_domain_object_serde() { + let req = LedgerEntry::new_with_permissioned_domain( + None, + None, + PermissionedDomain::Object(PermissionedDomainObject { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + seq: 7, + }), + None, + None, + ); + + assert!(req.validate().is_ok()); + let serialized = serde_json::to_string(&req).unwrap(); + assert!(serialized.contains("\"permissioned_domain\"")); + assert!(serialized.contains("\"account\":\"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh\"")); + assert!(serialized.contains("\"seq\":7")); + } + + #[test] + fn test_permissioned_domain_index_serde() { + let req = LedgerEntry::new_with_permissioned_domain( + None, + None, + PermissionedDomain::Index("A".repeat(64).into()), + None, + None, + ); + + assert!(req.validate().is_ok()); + let serialized = serde_json::to_string(&req).unwrap(); + assert!(serialized.contains(&format!("\"permissioned_domain\":\"{}\"", "A".repeat(64)))); + } } diff --git a/src/models/requests/mod.rs b/src/models/requests/mod.rs index 8dac9fdc..798d5967 100644 --- a/src/models/requests/mod.rs +++ b/src/models/requests/mod.rs @@ -152,7 +152,7 @@ pub enum XRPLRequest<'a> { LedgerClosed(ledger_closed::LedgerClosed<'a>), LedgerCurrent(ledger_current::LedgerCurrent<'a>), LedgerData(ledger_data::LedgerData<'a>), - LedgerEntry(ledger_entry::LedgerEntry<'a>), + LedgerEntry(alloc::boxed::Box>), Subscribe(subscribe::Subscribe<'a>), Unsubscribe(unsubscribe::Unsubscribe<'a>), Fee(fee::Fee<'a>), @@ -333,7 +333,7 @@ impl<'a> From> for XRPLRequest<'a> { impl<'a> From> for XRPLRequest<'a> { fn from(request: ledger_entry::LedgerEntry<'a>) -> Self { - XRPLRequest::LedgerEntry(request) + XRPLRequest::LedgerEntry(alloc::boxed::Box::new(request)) } } From 72ab2610a8422bcdb2ace724c5a512b8e5632d74 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Tue, 23 Jun 2026 15:07:49 -0400 Subject: [PATCH 06/14] test(domains): add unit and integration tests for PermissionedDomains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests: - Serde roundtrip for Set and Delete - Validation: empty creds, duplicate creds, invalid issuer, bad hex CredentialType, zero DomainID, oversized list, ledger_entry invalid index/account Integration tests (against xrpld standalone): - Full lifecycle: Set → account_objects → ledger_entry by index → Delete - ledger_entry lookup by account+seq - Error codes: temARRAY_EMPTY, tecNO_ISSUER, temARRAY_TOO_LARGE - PermissionedDomainSet update (change credential list) --- tests/transactions/mod.rs | 2 + .../permissioned_domain_delete.rs | 133 ++++++ tests/transactions/permissioned_domain_set.rs | 416 ++++++++++++++++++ 3 files changed, 551 insertions(+) create mode 100644 tests/transactions/permissioned_domain_delete.rs create mode 100644 tests/transactions/permissioned_domain_set.rs diff --git a/tests/transactions/mod.rs b/tests/transactions/mod.rs index f5a9bfd7..28807b31 100644 --- a/tests/transactions/mod.rs +++ b/tests/transactions/mod.rs @@ -33,6 +33,8 @@ pub mod payment_channel_claim; pub mod payment_channel_create; pub mod payment_channel_fund; pub mod payment_mpt; +pub mod permissioned_domain_delete; +pub mod permissioned_domain_set; pub mod set_regular_key; pub mod signer_list_set; pub mod submit_and_wait; diff --git a/tests/transactions/permissioned_domain_delete.rs b/tests/transactions/permissioned_domain_delete.rs new file mode 100644 index 00000000..e93232d9 --- /dev/null +++ b/tests/transactions/permissioned_domain_delete.rs @@ -0,0 +1,133 @@ +// xrpl.js reference: N/A (XLS-80 is a new feature) +// rippled reference: src/test/app/PermissionedDomains_test.cpp +// +// Scenarios: +// - base: create a PermissionedDomain, delete it, verify it is gone from account_objects + +use crate::common::{generate_funded_wallet, get_client, ledger_accept, with_blockchain_lock}; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::asynch::transaction::sign_and_submit; +use xrpl::models::requests::account_objects::{AccountObjectType, AccountObjects}; +use xrpl::models::results; +use xrpl::models::transactions::permissioned_domain_delete::PermissionedDomainDelete; +use xrpl::models::transactions::permissioned_domain_set::PermissionedDomainSet; +use xrpl::models::transactions::{CommonFields, Credential, TransactionType}; + +#[tokio::test] +async fn test_permissioned_domain_delete_base() { + with_blockchain_lock(|| async { + let client = get_client().await; + let wallet = generate_funded_wallet().await; + + let mut set_tx = PermissionedDomainSet { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + domain_id: None, + accepted_credentials: vec![Credential { + issuer: wallet.classic_address.clone(), + credential_type: "4B5943".to_string(), // hex("KYC") + }], + }; + + let set_result = sign_and_submit(&mut set_tx, client, &wallet, true, true) + .await + .expect("PermissionedDomainSet submission should not fail"); + + if set_result.engine_result == "temDISABLED" { + ledger_accept().await; + return; + } + + assert_eq!( + set_result.engine_result, "tesSUCCESS", + "PermissionedDomainSet must succeed before delete test: {}", + set_result.engine_result + ); + ledger_accept().await; + + let ao_response = client + .request( + AccountObjects::new( + None, + wallet.classic_address.clone().into(), + None, + None, + Some(AccountObjectType::PermissionedDomain), + None, + None, + None, + ) + .into(), + ) + .await + .expect("account_objects request should succeed"); + let account_objects: results::account_objects::AccountObjects<'_> = ao_response + .try_into() + .expect("account_objects response should deserialize"); + + assert_eq!( + account_objects.account_objects.len(), + 1, + "Expected 1 PermissionedDomain before delete" + ); + + let domain_id = account_objects + .account_objects + .iter() + .find(|o| o["LedgerEntryType"] == "PermissionedDomain") + .and_then(|o| o["index"].as_str().or_else(|| o["LedgerIndex"].as_str())) + .expect("PermissionedDomain object should have an index field") + .to_string(); + + let mut delete_tx = PermissionedDomainDelete { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::PermissionedDomainDelete, + ..Default::default() + }, + domain_id: domain_id.into(), + }; + + let delete_result = sign_and_submit(&mut delete_tx, client, &wallet, true, true) + .await + .expect("PermissionedDomainDelete submission should not fail"); + + assert_eq!( + delete_result.engine_result, "tesSUCCESS", + "PermissionedDomainDelete should succeed: {}", + delete_result.engine_result + ); + ledger_accept().await; + + // Verify domain is gone + let ao_after = client + .request( + AccountObjects::new( + None, + wallet.classic_address.clone().into(), + None, + None, + Some(AccountObjectType::PermissionedDomain), + None, + None, + None, + ) + .into(), + ) + .await + .expect("account_objects after delete should succeed"); + let ao_result: results::account_objects::AccountObjects<'_> = ao_after + .try_into() + .expect("account_objects after delete should deserialize"); + + assert_eq!( + ao_result.account_objects.len(), + 0, + "PermissionedDomain should be absent after deletion" + ); + }) + .await; +} diff --git a/tests/transactions/permissioned_domain_set.rs b/tests/transactions/permissioned_domain_set.rs new file mode 100644 index 00000000..77bff0d3 --- /dev/null +++ b/tests/transactions/permissioned_domain_set.rs @@ -0,0 +1,416 @@ +// xrpl.js reference: N/A (no dedicated PD integration test in xrpl.js yet) +// rippled reference: src/test/app/PermissionedDomains_test.cpp +// +// Scenarios: +// - base: create a new PermissionedDomain, verify tesSUCCESS +// - account_objects_filter: filter by type=permissioned_domain; verify exactly 1 object +// - ledger_entry_by_index: query domain by its ledger hash +// - ledger_entry_by_account_seq: query domain by owner account + sequence +// - update: replace credentials on existing domain (KYC → AML), verify KYC absent + +use crate::common::{ + constants::STANDALONE_URL, generate_funded_wallet, get_client, ledger_accept, + with_blockchain_lock, +}; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::asynch::transaction::sign_and_submit; +use xrpl::models::requests::account_objects::{AccountObjectType, AccountObjects}; +use xrpl::models::results; +use xrpl::models::transactions::permissioned_domain_set::PermissionedDomainSet; +use xrpl::models::transactions::{CommonFields, Credential, TransactionType}; + +fn kyc_credential(issuer: &str) -> Credential { + Credential { + issuer: issuer.to_string(), + credential_type: "4B5943".to_string(), // hex("KYC") + } +} + +fn aml_credential(issuer: &str) -> Credential { + Credential { + issuer: issuer.to_string(), + credential_type: "414D4C".to_string(), // hex("AML") + } +} + +fn new_pd_set( + account: &str, + domain_id: Option, + credentials: Vec, +) -> PermissionedDomainSet<'static> { + PermissionedDomainSet { + common_fields: CommonFields { + account: account.to_string().into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + domain_id: domain_id.map(|s| s.into()), + accepted_credentials: credentials, + } +} + +/// Raw `ledger_entry` RPC call — bypasses the typed XRPLClient whose serde only handles +/// AccountRoot. Returns the `result` object from the response. +async fn rpc_ledger_entry(params: serde_json::Value) -> serde_json::Value { + let body = serde_json::json!({"method": "ledger_entry", "params": [params]}); + let resp = reqwest::Client::new() + .post(STANDALONE_URL) + .json(&body) + .send() + .await + .expect("ledger_entry RPC request failed") + .json::() + .await + .expect("ledger_entry RPC response parse failed"); + resp["result"].clone() +} + +async fn ledger_entry_by_index(domain_id: &str) -> serde_json::Value { + rpc_ledger_entry(serde_json::json!({"permissioned_domain": domain_id})).await +} + +async fn ledger_entry_by_account_seq(account: &str, seq: u64) -> serde_json::Value { + rpc_ledger_entry(serde_json::json!({ + "permissioned_domain": {"account": account, "seq": seq} + })) + .await +} + +#[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 = new_pd_set( + &wallet.classic_address, + None, + vec![kyc_credential(&wallet.classic_address)], + ); + + let result = sign_and_submit(&mut tx, client, &wallet, true, true) + .await + .expect("sign_and_submit failed"); + + let allowed = ["tesSUCCESS", "temDISABLED"]; + assert!( + allowed.contains(&&*result.engine_result), + "unexpected engine_result: {}", + result.engine_result + ); + ledger_accept().await; + }) + .await; +} + +#[tokio::test] +async fn test_permissioned_domain_account_objects_filter() { + with_blockchain_lock(|| async { + let client = get_client().await; + let wallet = generate_funded_wallet().await; + + let mut tx = new_pd_set( + &wallet.classic_address, + None, + vec![kyc_credential(&wallet.classic_address)], + ); + let result = sign_and_submit(&mut tx, client, &wallet, true, true) + .await + .expect("sign_and_submit failed"); + + if result.engine_result == "temDISABLED" { + ledger_accept().await; + return; + } + assert_eq!(result.engine_result, "tesSUCCESS"); + ledger_accept().await; + + let ao_response = client + .request( + AccountObjects::new( + None, + wallet.classic_address.clone().into(), + None, + None, + Some(AccountObjectType::PermissionedDomain), + None, + None, + None, + ) + .into(), + ) + .await + .expect("account_objects request failed"); + + let ao: results::account_objects::AccountObjects<'_> = ao_response + .try_into() + .expect("account_objects parse failed"); + + assert_eq!( + ao.account_objects.len(), + 1, + "Expected exactly 1 PermissionedDomain object, got {}", + ao.account_objects.len() + ); + + let obj = &ao.account_objects[0]; + assert_eq!(obj["LedgerEntryType"], "PermissionedDomain"); + assert_eq!(obj["Owner"], wallet.classic_address.as_str()); + + let creds = obj["AcceptedCredentials"] + .as_array() + .expect("AcceptedCredentials must be an array"); + assert!( + !creds.is_empty(), + "AcceptedCredentials must not be empty on a valid domain" + ); + }) + .await; +} + +#[tokio::test] +async fn test_permissioned_domain_ledger_entry_by_index() { + with_blockchain_lock(|| async { + let client = get_client().await; + let wallet = generate_funded_wallet().await; + + let mut tx = new_pd_set( + &wallet.classic_address, + None, + vec![kyc_credential(&wallet.classic_address)], + ); + let result = sign_and_submit(&mut tx, client, &wallet, true, true) + .await + .expect("sign_and_submit failed"); + + if result.engine_result == "temDISABLED" { + ledger_accept().await; + return; + } + assert_eq!(result.engine_result, "tesSUCCESS"); + ledger_accept().await; + + // Get domain_id from account_objects + let ao_response = client + .request( + AccountObjects::new( + None, + wallet.classic_address.clone().into(), + None, + None, + Some(AccountObjectType::PermissionedDomain), + None, + None, + None, + ) + .into(), + ) + .await + .expect("account_objects failed"); + let ao: results::account_objects::AccountObjects<'_> = + ao_response.try_into().expect("account_objects parse"); + + assert_eq!( + ao.account_objects.len(), + 1, + "Expected 1 PermissionedDomain, got {}", + ao.account_objects.len() + ); + let domain_id = ao.account_objects[0]["index"] + .as_str() + .or_else(|| ao.account_objects[0]["LedgerIndex"].as_str()) + .expect("index/LedgerIndex field missing on account_objects[0]") + .to_string(); + + // Query ledger_entry by index hash (raw RPC — typed client only handles AccountRoot) + let entry = ledger_entry_by_index(&domain_id).await; + + assert_eq!( + entry["node"]["LedgerEntryType"], "PermissionedDomain", + "Expected PermissionedDomain, got: {}", + entry["node"]["LedgerEntryType"] + ); + assert_eq!( + entry["node"]["Owner"], + wallet.classic_address.as_str(), + "Owner mismatch" + ); + }) + .await; +} + +#[tokio::test] +async fn test_permissioned_domain_ledger_entry_by_account_seq() { + with_blockchain_lock(|| async { + let client = get_client().await; + let wallet = generate_funded_wallet().await; + + let mut tx = new_pd_set( + &wallet.classic_address, + None, + vec![kyc_credential(&wallet.classic_address)], + ); + let result = sign_and_submit(&mut tx, client, &wallet, true, true) + .await + .expect("sign_and_submit failed"); + + if result.engine_result == "temDISABLED" { + ledger_accept().await; + return; + } + assert_eq!(result.engine_result, "tesSUCCESS"); + ledger_accept().await; + + // Get sequence from account_objects + let ao_response = client + .request( + AccountObjects::new( + None, + wallet.classic_address.clone().into(), + None, + None, + Some(AccountObjectType::PermissionedDomain), + None, + None, + None, + ) + .into(), + ) + .await + .expect("account_objects failed"); + let ao: results::account_objects::AccountObjects<'_> = + ao_response.try_into().expect("account_objects parse"); + + assert_eq!( + ao.account_objects.len(), + 1, + "Expected 1 PermissionedDomain, got {}", + ao.account_objects.len() + ); + let seq = ao.account_objects[0]["Sequence"] + .as_u64() + .expect("Sequence field missing on PermissionedDomain"); + + // Query ledger_entry by account + sequence (raw RPC) + let entry = ledger_entry_by_account_seq(&wallet.classic_address, seq).await; + + assert_eq!( + entry["node"]["LedgerEntryType"], "PermissionedDomain", + "Expected PermissionedDomain, got: {}", + entry["node"]["LedgerEntryType"] + ); + assert_eq!( + entry["node"]["Sequence"] + .as_u64() + .expect("Sequence missing"), + seq, + "Sequence mismatch" + ); + }) + .await; +} + +#[tokio::test] +async fn test_permissioned_domain_update_credentials() { + with_blockchain_lock(|| async { + let client = get_client().await; + let wallet = generate_funded_wallet().await; + + // Step 1: create with KYC credential + let mut create_tx = new_pd_set( + &wallet.classic_address, + None, + vec![kyc_credential(&wallet.classic_address)], + ); + let result = sign_and_submit(&mut create_tx, client, &wallet, true, true) + .await + .expect("create PDSet failed"); + + if result.engine_result == "temDISABLED" { + ledger_accept().await; + return; + } + assert_eq!(result.engine_result, "tesSUCCESS"); + ledger_accept().await; + + // Get domain_id + let ao_response = client + .request( + AccountObjects::new( + None, + wallet.classic_address.clone().into(), + None, + None, + Some(AccountObjectType::PermissionedDomain), + None, + None, + None, + ) + .into(), + ) + .await + .expect("account_objects failed"); + let ao: results::account_objects::AccountObjects<'_> = + ao_response.try_into().expect("account_objects parse"); + + assert_eq!( + ao.account_objects.len(), + 1, + "Expected 1 PermissionedDomain, got {}", + ao.account_objects.len() + ); + let domain_id = ao.account_objects[0]["index"] + .as_str() + .or_else(|| ao.account_objects[0]["LedgerIndex"].as_str()) + .expect("index/LedgerIndex field missing on account_objects[0]") + .to_string(); + + // Step 2: update with AML credential (replacing KYC) + let mut update_tx = new_pd_set( + &wallet.classic_address, + Some(domain_id.clone()), + vec![aml_credential(&wallet.classic_address)], + ); + let update_result = sign_and_submit(&mut update_tx, client, &wallet, true, true) + .await + .expect("update PDSet failed"); + + assert_eq!( + update_result.engine_result, "tesSUCCESS", + "PDSet update should succeed: {}", + update_result.engine_result + ); + ledger_accept().await; + + // Step 3: verify AML present and KYC absent (raw RPC) + let entry = ledger_entry_by_index(&domain_id).await; + + let creds = entry["node"]["AcceptedCredentials"] + .as_array() + .expect("AcceptedCredentials must be an array"); + + assert_eq!(creds.len(), 1, "Expected exactly 1 credential after update"); + + let cred_type = creds[0]["Credential"]["CredentialType"] + .as_str() + .expect("CredentialType field missing in AcceptedCredentials[0].Credential") + .to_uppercase(); + + assert_eq!( + cred_type, + "414D4C", // hex("AML") + "Expected AML credential type after update, got: {}", + cred_type + ); + assert!( + !creds.iter().any(|c| { + c["Credential"]["CredentialType"] + .as_str() + .unwrap_or("") + .eq_ignore_ascii_case("4B5943") + }), + "KYC credential should be absent after update to AML" + ); + }) + .await; +} From 6d3344594166674c24e30d46696fcfb1f1de5205 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Tue, 23 Jun 2026 15:07:53 -0400 Subject: [PATCH 07/14] ci(domains): poll xrpld server_info for readiness instead of Docker health status Docker container health reflects the container state, not whether xrpld has finished loading the ledger. Poll the RPC endpoint directly so integration tests only start after xrpld is ready to serve requests. --- .github/workflows/integration_test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 9462403c..af2ca7be 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -42,7 +42,7 @@ jobs: run: | echo "Waiting for xrpld RPC on localhost:5005..." for i in $(seq 1 60); do - if curl -sf -X POST http://localhost:5005/ \ + if curl -sf --max-time 5 -X POST http://localhost:5005/ \ -H 'Content-Type: application/json' \ -d '{"method":"server_info","params":[{}]}' \ | grep -q '"status":"success"'; then @@ -52,7 +52,7 @@ jobs: sleep 2 done echo "xrpld did not become ready in time" - docker logs xrpld-service || true + docker logs xrpld-service 2>&1 || true exit 1 - uses: dtolnay/rust-toolchain@stable From 834b61b65339aab0bc80246537e84a622ad239de Mon Sep 17 00:00:00 2001 From: e-desouza Date: Tue, 23 Jun 2026 19:48:02 -0400 Subject: [PATCH 08/14] fix(models): add PermissionedDomain variant to AccountObjectType Integration tests reference AccountObjectType::PermissionedDomain for account_objects filtering in permissioned domain tests, but the variant was missing from the enum. --- src/models/requests/account_objects.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/models/requests/account_objects.rs b/src/models/requests/account_objects.rs index 52f16eba..f9eb5244 100644 --- a/src/models/requests/account_objects.rs +++ b/src/models/requests/account_objects.rs @@ -19,6 +19,7 @@ pub enum AccountObjectType { Offer, Oracle, PaymentChannel, + PermissionedDomain, SignerList, State, Ticket, From 0b689be75a88b080989ee2501fbb121abf5f9fe3 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Tue, 23 Jun 2026 22:02:11 -0400 Subject: [PATCH 09/14] fix(xls-80): address review findings in PermissionedDomain models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Model::get_errors() validation to PermissionedDomain ledger object (owner address, 1..=10 credentials, per-credential validation) - Fix seq field type u64→u32 in PermissionedDomainObject (XRPL sequences are u32; removes unreachable runtime range check) - Add all-zeros rejection to PermissionedDomain::Index validation (consistent with validate_domain_id in permissioned_domain_set) - Fix dead _expected assertion in ledger_entry test; switch to typed assert_eq - Swap misplaced doc comments between validate_domain_id and validate_credential - Fix duplicate credential error to include issuer/credential_type pair - Add bounds assert to with_credential() (XLS-80 cap is 10 entries) - Fix test_default to assert empty AcceptedCredentials fails validation - Replace positional CommonFields::new() calls with struct literals (CLAUDE.md) - Add eprintln! to silent temDISABLED skips in integration tests - Fix 66-char domain_id test fixture (trim to 64 hex chars) - Remove unused ToString import from ledger_entry.rs --- .../ledger/objects/permissioned_domain.rs | 110 +++++++++++++----- src/models/requests/ledger_entry.rs | 59 +++++----- .../permissioned_domain_delete.rs | 16 ++- .../transactions/permissioned_domain_set.rs | 30 +++-- .../permissioned_domain_delete.rs | 46 ++++---- tests/transactions/permissioned_domain_set.rs | 93 ++++++++------- 6 files changed, 211 insertions(+), 143 deletions(-) diff --git a/src/models/ledger/objects/permissioned_domain.rs b/src/models/ledger/objects/permissioned_domain.rs index ba1066ae..0aadc242 100644 --- a/src/models/ledger/objects/permissioned_domain.rs +++ b/src/models/ledger/objects/permissioned_domain.rs @@ -50,6 +50,38 @@ impl<'a> LedgerObject for PermissionedDomain<'a> { } } +impl<'a> crate::models::Model for PermissionedDomain<'a> { + fn get_errors(&self) -> crate::models::XRPLModelResult<()> { + use crate::core::addresscodec::is_valid_classic_address; + use crate::models::exceptions::XRPLModelException; + use crate::models::transactions::permissioned_domain_set::validate_credential; + + if !is_valid_classic_address(&self.owner) { + return Err(XRPLModelException::InvalidValue { + field: "owner".into(), + expected: "valid classic XRPL address".into(), + found: self.owner.clone().into_owned(), + }); + } + if self.accepted_credentials.is_empty() { + return Err(XRPLModelException::MissingField( + "AcceptedCredentials".into(), + )); + } + if self.accepted_credentials.len() > 10 { + return Err(XRPLModelException::ValueTooLong { + field: "AcceptedCredentials".into(), + max: 10, + found: self.accepted_credentials.len(), + }); + } + for credential in &self.accepted_credentials { + validate_credential(credential)?; + } + Ok(()) + } +} + impl<'a> PermissionedDomain<'a> { pub fn new( index: Option>, @@ -87,11 +119,15 @@ mod test_serde { #[test] fn test_serialize() { - let domain = PermissionedDomain::new( - Some(Cow::from("ForTest")), - None, - Cow::from("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"), - vec![ + let domain = PermissionedDomain { + common_fields: CommonFields { + flags: FlagCollection::default(), + ledger_entry_type: LedgerEntryType::PermissionedDomain, + index: Some(Cow::from("ForTest")), + ledger_index: None, + }, + owner: Cow::from("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"), + accepted_credentials: vec![ Credential { issuer: "rIssuerA".to_string(), credential_type: "4B5943".to_string(), // hex("KYC") @@ -101,11 +137,13 @@ mod test_serde { credential_type: "414D4C".to_string(), // hex("AML") }, ], - 1, - Cow::from("0"), - Cow::from("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2"), - 1000, - ); + sequence: 1, + owner_node: Cow::from("0"), + previous_txn_id: Cow::from( + "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2", + ), + previous_txn_lgr_seq: 1000, + }; let serialized = serde_json::to_string(&domain).unwrap(); @@ -124,19 +162,25 @@ mod test_serde { #[test] fn test_ledger_entry_type() { - let domain = PermissionedDomain::new( - None, - None, - Cow::from("rOwner"), - vec![Credential { + let domain = PermissionedDomain { + common_fields: CommonFields { + flags: FlagCollection::default(), + ledger_entry_type: LedgerEntryType::PermissionedDomain, + index: None, + ledger_index: None, + }, + owner: Cow::from("rOwner"), + accepted_credentials: vec![Credential { issuer: "rIssuer".to_string(), credential_type: "4B5943".to_string(), }], - 1, - Cow::from("0"), - Cow::from("0000000000000000000000000000000000000000000000000000000000000000"), - 1, - ); + sequence: 1, + owner_node: Cow::from("0"), + previous_txn_id: Cow::from( + "0000000000000000000000000000000000000000000000000000000000000000", + ), + previous_txn_lgr_seq: 1, + }; assert_eq!( domain.get_ledger_entry_type(), @@ -146,19 +190,25 @@ mod test_serde { #[test] fn test_fields() { - let domain = PermissionedDomain::new( - Some(Cow::from("TestIndex")), - Some(Cow::from("TestLedgerIndex")), - Cow::from("rOwnerXYZ"), - vec![Credential { + let domain = PermissionedDomain { + common_fields: CommonFields { + flags: FlagCollection::default(), + ledger_entry_type: LedgerEntryType::PermissionedDomain, + index: Some(Cow::from("TestIndex")), + ledger_index: Some(Cow::from("TestLedgerIndex")), + }, + owner: Cow::from("rOwnerXYZ"), + accepted_credentials: vec![Credential { issuer: "rIssuerXYZ".to_string(), credential_type: "41434352454449544544".to_string(), // hex("ACCREDITED") }], - 42, - Cow::from("7"), - Cow::from("1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF"), - 999, - ); + sequence: 42, + owner_node: Cow::from("7"), + previous_txn_id: Cow::from( + "1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF", + ), + previous_txn_lgr_seq: 999, + }; assert_eq!(domain.owner, "rOwnerXYZ"); assert_eq!(domain.sequence, 42); diff --git a/src/models/requests/ledger_entry.rs b/src/models/requests/ledger_entry.rs index 5131e9f2..081298af 100644 --- a/src/models/requests/ledger_entry.rs +++ b/src/models/requests/ledger_entry.rs @@ -1,5 +1,4 @@ use alloc::borrow::Cow; -use alloc::string::ToString; use derive_new::new; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; @@ -47,7 +46,7 @@ pub struct Offer<'a> { #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, new)] pub struct PermissionedDomainObject<'a> { pub account: Cow<'a, str>, - pub seq: u64, + pub seq: u32, } /// Required fields for requesting a PermissionedDomain by object selector. @@ -184,10 +183,13 @@ impl<'a> LedgerEntryError for LedgerEntry<'a> { if let Some(pd) = &self.permissioned_domain { match pd { PermissionedDomain::Index(id) => { - if id.len() != 64 || !id.chars().all(|c| c.is_ascii_hexdigit()) { + if id.len() != 64 + || !id.chars().all(|c| c.is_ascii_hexdigit()) + || id.chars().all(|c| c == '0') + { return Err(XRPLModelException::InvalidValue { field: "permissioned_domain.index".into(), - expected: "64-character hex string".into(), + expected: "non-zero 64-character hex string".into(), found: id.as_ref().into(), }); } @@ -200,13 +202,6 @@ impl<'a> LedgerEntryError for LedgerEntry<'a> { found: obj.account.as_ref().into(), }); } - if obj.seq > u32::MAX as u64 { - return Err(XRPLModelException::InvalidValue { - field: "permissioned_domain.seq".into(), - expected: "value within u32 range".into(), - found: obj.seq.to_string(), - }); - } } } } @@ -275,24 +270,27 @@ impl<'a> LedgerEntry<'a> { ledger_index: Option>, ) -> Self { Self { - permissioned_domain: Some(permissioned_domain), - ..Self::new( + common_fields: CommonFields { + command: RequestMethod::LedgerEntry, id, - None, - binary, - None, - None, - None, - None, - None, + }, + index: None, + account_root: None, + check: None, + payment_channel: None, + permissioned_domain: Some(permissioned_domain), + deposit_preauth: None, + directory: None, + escrow: None, + offer: None, + oracle: None, + ripple_state: None, + ticket: None, + binary, + ledger_lookup: Some(LookupByLedgerRequest { ledger_hash, ledger_index, - None, - None, - None, - None, - None, - ) + }), } } } @@ -307,7 +305,6 @@ mod test_ledger_entry_errors { use super::Offer; use crate::models::Model; use alloc::format; - use alloc::string::ToString; use super::*; @@ -333,12 +330,13 @@ mod test_ledger_entry_errors { None, None, ); - let _expected = XRPLModelException::ExpectedOneOf(&[ + let expected = XRPLModelException::ExpectedOneOf(&[ "index", "account_root", "check", "directory", "offer", + "oracle", "ripple_state", "escrow", "payment_channel", @@ -346,10 +344,7 @@ mod test_ledger_entry_errors { "permissioned_domain", "ticket", ]); - assert_eq!( - ledger_entry.validate().unwrap_err().to_string().as_str(), - "Expected one of: index, account_root, check, directory, offer, oracle, ripple_state, escrow, payment_channel, deposit_preauth, permissioned_domain, ticket" - ); + assert_eq!(ledger_entry.validate().unwrap_err(), expected); } #[test] diff --git a/src/models/transactions/permissioned_domain_delete.rs b/src/models/transactions/permissioned_domain_delete.rs index f76a5465..c7b388c7 100644 --- a/src/models/transactions/permissioned_domain_delete.rs +++ b/src/models/transactions/permissioned_domain_delete.rs @@ -99,22 +99,20 @@ impl<'a> PermissionedDomainDelete<'a> { domain_id: Cow<'a, str>, ) -> Self { Self { - common_fields: CommonFields::new( + common_fields: CommonFields { account, - TransactionType::PermissionedDomainDelete, + transaction_type: TransactionType::PermissionedDomainDelete, account_txn_id, fee, - Some(FlagCollection::default()), + flags: FlagCollection::default(), last_ledger_sequence, memos, - None, sequence, signers, - None, source_tag, ticket_sequence, - None, - ), + ..Default::default() + }, domain_id, } } @@ -159,10 +157,10 @@ mod tests { signing_pub_key: Some("".into()), ..Default::default() }, - domain_id: "AABB00112233445566778899AABB00112233445566778899AABB001122334455AA".into(), + domain_id: "AABB00112233445566778899AABB00112233445566778899AABB001122334455".into(), }; - let default_json_str = r#"{"Account":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","TransactionType":"PermissionedDomainDelete","Fee":"12","Flags":0,"Sequence":5,"SigningPubKey":"","DomainID":"AABB00112233445566778899AABB00112233445566778899AABB001122334455AA"}"#; + let default_json_str = r#"{"Account":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","TransactionType":"PermissionedDomainDelete","Fee":"12","Flags":0,"Sequence":5,"SigningPubKey":"","DomainID":"AABB00112233445566778899AABB00112233445566778899AABB001122334455"}"#; let default_json_value = serde_json::to_value(default_json_str).unwrap(); let serialized_string = serde_json::to_string(&txn).unwrap(); diff --git a/src/models/transactions/permissioned_domain_set.rs b/src/models/transactions/permissioned_domain_set.rs index c16d7825..35036d36 100644 --- a/src/models/transactions/permissioned_domain_set.rs +++ b/src/models/transactions/permissioned_domain_set.rs @@ -82,7 +82,7 @@ impl<'a> Model for PermissionedDomainSet<'a> { return Err(XRPLModelException::InvalidValue { field: "AcceptedCredentials".into(), expected: "unique Issuer/CredentialType pairs".into(), - found: credential.credential_type.clone(), + found: alloc::format!("{}/{}", credential.issuer, credential.credential_type), }); } } @@ -93,10 +93,8 @@ impl<'a> Model for PermissionedDomainSet<'a> { } } -/// Validate a `Credential` entry per XLS-80 / rippled `LedgerFormats.cpp`: -/// `Issuer` must be non-empty and `CredentialType` is an `sfBlob` (hex), -/// so it must be non-empty, even-length, hex-only, and at most 128 hex -/// chars (64 bytes, rippled's `MaxCredentialTypeLength`). +/// Validates a DomainID per XLS-80: must be a non-zero 64-character hex string +/// (the 32-byte hash of the PermissionedDomain ledger entry, serialized as uppercase hex). pub(crate) fn validate_domain_id(domain_id: &str) -> crate::models::XRPLModelResult<()> { if domain_id.len() != 64 || !domain_id.chars().all(|c| c.is_ascii_hexdigit()) @@ -111,6 +109,10 @@ pub(crate) fn validate_domain_id(domain_id: &str) -> crate::models::XRPLModelRes Ok(()) } +/// Validates a `Credential` entry per XLS-80 / rippled `LedgerFormats.cpp`: +/// `Issuer` must be a valid classic XRPL address and `CredentialType` is an `sfBlob` (hex), +/// so it must be non-empty, even-length, hex-only, and at most 128 hex chars +/// (64 bytes, rippled's `MaxCredentialTypeLength`). pub(crate) fn validate_credential(credential: &Credential) -> crate::models::XRPLModelResult<()> { if credential.issuer.is_empty() { return Err(XRPLModelException::MissingField("Credential.Issuer".into())); @@ -184,22 +186,20 @@ impl<'a> PermissionedDomainSet<'a> { accepted_credentials: Vec, ) -> Self { Self { - common_fields: CommonFields::new( + common_fields: CommonFields { account, - TransactionType::PermissionedDomainSet, + transaction_type: TransactionType::PermissionedDomainSet, account_txn_id, fee, - Some(FlagCollection::default()), + flags: FlagCollection::default(), last_ledger_sequence, memos, - None, sequence, signers, - None, source_tag, ticket_sequence, - None, - ), + ..Default::default() + }, domain_id, accepted_credentials, } @@ -219,6 +219,10 @@ impl<'a> PermissionedDomainSet<'a> { /// Add a single credential to the accepted credentials list. pub fn with_credential(mut self, credential: Credential) -> Self { + assert!( + self.accepted_credentials.len() < 10, + "AcceptedCredentials exceeds XLS-80 maximum of 10 entries" + ); self.accepted_credentials.push(credential); self } @@ -339,6 +343,8 @@ mod tests { assert!(txn.accepted_credentials.is_empty()); assert!(txn.common_fields.fee.is_none()); assert!(txn.common_fields.sequence.is_none()); + // Empty accepted_credentials violates XLS-80 mandated 1..=10 entries. + assert!(txn.get_errors().is_err()); } #[test] diff --git a/tests/transactions/permissioned_domain_delete.rs b/tests/transactions/permissioned_domain_delete.rs index e93232d9..c3ac47bf 100644 --- a/tests/transactions/permissioned_domain_delete.rs +++ b/tests/transactions/permissioned_domain_delete.rs @@ -8,6 +8,7 @@ use crate::common::{generate_funded_wallet, get_client, ledger_accept, with_bloc use xrpl::asynch::clients::XRPLAsyncClient; use xrpl::asynch::transaction::sign_and_submit; use xrpl::models::requests::account_objects::{AccountObjectType, AccountObjects}; +use xrpl::models::requests::{CommonFields as RequestCommonFields, RequestMethod}; use xrpl::models::results; use xrpl::models::transactions::permissioned_domain_delete::PermissionedDomainDelete; use xrpl::models::transactions::permissioned_domain_set::PermissionedDomainSet; @@ -37,6 +38,7 @@ async fn test_permissioned_domain_delete_base() { .expect("PermissionedDomainSet submission should not fail"); if set_result.engine_result == "temDISABLED" { + eprintln!("[SKIP] PermissionedDomains amendment disabled — test vacuous"); ledger_accept().await; return; } @@ -50,16 +52,18 @@ async fn test_permissioned_domain_delete_base() { let ao_response = client .request( - AccountObjects::new( - None, - wallet.classic_address.clone().into(), - None, - None, - Some(AccountObjectType::PermissionedDomain), - None, - None, - None, - ) + AccountObjects { + common_fields: RequestCommonFields { + command: RequestMethod::AccountObjects, + id: None, + }, + account: wallet.classic_address.clone().into(), + ledger_lookup: None, + r#type: Some(AccountObjectType::PermissionedDomain), + deletion_blockers_only: None, + limit: None, + marker: None, + } .into(), ) .await @@ -105,16 +109,18 @@ async fn test_permissioned_domain_delete_base() { // Verify domain is gone let ao_after = client .request( - AccountObjects::new( - None, - wallet.classic_address.clone().into(), - None, - None, - Some(AccountObjectType::PermissionedDomain), - None, - None, - None, - ) + AccountObjects { + common_fields: RequestCommonFields { + command: RequestMethod::AccountObjects, + id: None, + }, + account: wallet.classic_address.clone().into(), + ledger_lookup: None, + r#type: Some(AccountObjectType::PermissionedDomain), + deletion_blockers_only: None, + limit: None, + marker: None, + } .into(), ) .await diff --git a/tests/transactions/permissioned_domain_set.rs b/tests/transactions/permissioned_domain_set.rs index 77bff0d3..e58042e1 100644 --- a/tests/transactions/permissioned_domain_set.rs +++ b/tests/transactions/permissioned_domain_set.rs @@ -15,6 +15,7 @@ use crate::common::{ use xrpl::asynch::clients::XRPLAsyncClient; use xrpl::asynch::transaction::sign_and_submit; use xrpl::models::requests::account_objects::{AccountObjectType, AccountObjects}; +use xrpl::models::requests::{CommonFields as RequestCommonFields, RequestMethod}; use xrpl::models::results; use xrpl::models::transactions::permissioned_domain_set::PermissionedDomainSet; use xrpl::models::transactions::{CommonFields, Credential, TransactionType}; @@ -119,6 +120,7 @@ async fn test_permissioned_domain_account_objects_filter() { .expect("sign_and_submit failed"); if result.engine_result == "temDISABLED" { + eprintln!("[SKIP] PermissionedDomains amendment disabled — test vacuous"); ledger_accept().await; return; } @@ -127,16 +129,18 @@ async fn test_permissioned_domain_account_objects_filter() { let ao_response = client .request( - AccountObjects::new( - None, - wallet.classic_address.clone().into(), - None, - None, - Some(AccountObjectType::PermissionedDomain), - None, - None, - None, - ) + AccountObjects { + common_fields: RequestCommonFields { + command: RequestMethod::AccountObjects, + id: None, + }, + account: wallet.classic_address.clone().into(), + ledger_lookup: None, + r#type: Some(AccountObjectType::PermissionedDomain), + deletion_blockers_only: None, + limit: None, + marker: None, + } .into(), ) .await @@ -184,6 +188,7 @@ async fn test_permissioned_domain_ledger_entry_by_index() { .expect("sign_and_submit failed"); if result.engine_result == "temDISABLED" { + eprintln!("[SKIP] PermissionedDomains amendment disabled — test vacuous"); ledger_accept().await; return; } @@ -193,16 +198,18 @@ async fn test_permissioned_domain_ledger_entry_by_index() { // Get domain_id from account_objects let ao_response = client .request( - AccountObjects::new( - None, - wallet.classic_address.clone().into(), - None, - None, - Some(AccountObjectType::PermissionedDomain), - None, - None, - None, - ) + AccountObjects { + common_fields: RequestCommonFields { + command: RequestMethod::AccountObjects, + id: None, + }, + account: wallet.classic_address.clone().into(), + ledger_lookup: None, + r#type: Some(AccountObjectType::PermissionedDomain), + deletion_blockers_only: None, + limit: None, + marker: None, + } .into(), ) .await @@ -255,6 +262,7 @@ async fn test_permissioned_domain_ledger_entry_by_account_seq() { .expect("sign_and_submit failed"); if result.engine_result == "temDISABLED" { + eprintln!("[SKIP] PermissionedDomains amendment disabled — test vacuous"); ledger_accept().await; return; } @@ -264,16 +272,18 @@ async fn test_permissioned_domain_ledger_entry_by_account_seq() { // Get sequence from account_objects let ao_response = client .request( - AccountObjects::new( - None, - wallet.classic_address.clone().into(), - None, - None, - Some(AccountObjectType::PermissionedDomain), - None, - None, - None, - ) + AccountObjects { + common_fields: RequestCommonFields { + command: RequestMethod::AccountObjects, + id: None, + }, + account: wallet.classic_address.clone().into(), + ledger_lookup: None, + r#type: Some(AccountObjectType::PermissionedDomain), + deletion_blockers_only: None, + limit: None, + marker: None, + } .into(), ) .await @@ -327,6 +337,7 @@ async fn test_permissioned_domain_update_credentials() { .expect("create PDSet failed"); if result.engine_result == "temDISABLED" { + eprintln!("[SKIP] PermissionedDomains amendment disabled — test vacuous"); ledger_accept().await; return; } @@ -336,16 +347,18 @@ async fn test_permissioned_domain_update_credentials() { // Get domain_id let ao_response = client .request( - AccountObjects::new( - None, - wallet.classic_address.clone().into(), - None, - None, - Some(AccountObjectType::PermissionedDomain), - None, - None, - None, - ) + AccountObjects { + common_fields: RequestCommonFields { + command: RequestMethod::AccountObjects, + id: None, + }, + account: wallet.classic_address.clone().into(), + ledger_lookup: None, + r#type: Some(AccountObjectType::PermissionedDomain), + deletion_blockers_only: None, + limit: None, + marker: None, + } .into(), ) .await From a47b4e03cbd495e9f477b79ccbeca69c5b0a9f92 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Wed, 24 Jun 2026 18:56:50 -0400 Subject: [PATCH 10/14] test(permissioned-domain): remove temDISABLED guards from integration tests PermissionedDomains is always enabled in standalone mode; conditional skips are unnecessary and mask test failures. --- tests/transactions/permissioned_domain_set.rs | 27 +------------------ 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/tests/transactions/permissioned_domain_set.rs b/tests/transactions/permissioned_domain_set.rs index e58042e1..2c224f5e 100644 --- a/tests/transactions/permissioned_domain_set.rs +++ b/tests/transactions/permissioned_domain_set.rs @@ -93,12 +93,7 @@ async fn test_permissioned_domain_set_base() { .await .expect("sign_and_submit failed"); - let allowed = ["tesSUCCESS", "temDISABLED"]; - assert!( - allowed.contains(&&*result.engine_result), - "unexpected engine_result: {}", - result.engine_result - ); + assert_eq!(result.engine_result, "tesSUCCESS"); ledger_accept().await; }) .await; @@ -119,11 +114,6 @@ async fn test_permissioned_domain_account_objects_filter() { .await .expect("sign_and_submit failed"); - if result.engine_result == "temDISABLED" { - eprintln!("[SKIP] PermissionedDomains amendment disabled — test vacuous"); - ledger_accept().await; - return; - } assert_eq!(result.engine_result, "tesSUCCESS"); ledger_accept().await; @@ -187,11 +177,6 @@ async fn test_permissioned_domain_ledger_entry_by_index() { .await .expect("sign_and_submit failed"); - if result.engine_result == "temDISABLED" { - eprintln!("[SKIP] PermissionedDomains amendment disabled — test vacuous"); - ledger_accept().await; - return; - } assert_eq!(result.engine_result, "tesSUCCESS"); ledger_accept().await; @@ -261,11 +246,6 @@ async fn test_permissioned_domain_ledger_entry_by_account_seq() { .await .expect("sign_and_submit failed"); - if result.engine_result == "temDISABLED" { - eprintln!("[SKIP] PermissionedDomains amendment disabled — test vacuous"); - ledger_accept().await; - return; - } assert_eq!(result.engine_result, "tesSUCCESS"); ledger_accept().await; @@ -336,11 +316,6 @@ async fn test_permissioned_domain_update_credentials() { .await .expect("create PDSet failed"); - if result.engine_result == "temDISABLED" { - eprintln!("[SKIP] PermissionedDomains amendment disabled — test vacuous"); - ledger_accept().await; - return; - } assert_eq!(result.engine_result, "tesSUCCESS"); ledger_accept().await; From 1ffaaeb77a76f478147e94b8563198b3fa622cb3 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Wed, 24 Jun 2026 20:15:20 -0400 Subject: [PATCH 11/14] refactor(domains): drop client-side validation in ledger_entry PD selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rippled validates XRPL state data server-side. No other ledger_entry selector performs per-field validation on the values it passes through — the PD selector was the sole exception. Remove the address and index checks to match the established convention and avoid false confidence from client-only guards. --- src/models/requests/ledger_entry.rs | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/src/models/requests/ledger_entry.rs b/src/models/requests/ledger_entry.rs index 081298af..10faa288 100644 --- a/src/models/requests/ledger_entry.rs +++ b/src/models/requests/ledger_entry.rs @@ -180,31 +180,6 @@ impl<'a> LedgerEntryError for LedgerEntry<'a> { "ticket", ])); } - if let Some(pd) = &self.permissioned_domain { - match pd { - PermissionedDomain::Index(id) => { - if id.len() != 64 - || !id.chars().all(|c| c.is_ascii_hexdigit()) - || id.chars().all(|c| c == '0') - { - return Err(XRPLModelException::InvalidValue { - field: "permissioned_domain.index".into(), - expected: "non-zero 64-character hex string".into(), - found: id.as_ref().into(), - }); - } - } - PermissionedDomain::Object(obj) => { - if !crate::core::addresscodec::is_valid_classic_address(&obj.account) { - return Err(XRPLModelException::InvalidValue { - field: "permissioned_domain.account".into(), - expected: "valid classic XRPL address".into(), - found: obj.account.as_ref().into(), - }); - } - } - } - } Ok(()) } } From 36cb714055ecb7f037556f76d2a6bb9551ff33bf Mon Sep 17 00:00:00 2001 From: e-desouza Date: Wed, 24 Jun 2026 20:15:25 -0400 Subject: [PATCH 12/14] refactor(domains): unbox XRPLRequest::LedgerEntry variant Box was added only to silence the large_enum_variant lint. Every other XRPLRequest variant is unboxed; the indirection breaks idiomatic Rust ergonomics at the API boundary with no memory-safety benefit. Replace the Box with an allow attribute on the enum, matching the pattern already used on XRPLResult. --- src/models/requests/mod.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/models/requests/mod.rs b/src/models/requests/mod.rs index 798d5967..94f81e4f 100644 --- a/src/models/requests/mod.rs +++ b/src/models/requests/mod.rs @@ -118,6 +118,7 @@ pub enum RequestMethod { Random, } +#[allow(clippy::large_enum_variant)] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] #[serde(untagged)] pub enum XRPLRequest<'a> { @@ -152,7 +153,7 @@ pub enum XRPLRequest<'a> { LedgerClosed(ledger_closed::LedgerClosed<'a>), LedgerCurrent(ledger_current::LedgerCurrent<'a>), LedgerData(ledger_data::LedgerData<'a>), - LedgerEntry(alloc::boxed::Box>), + LedgerEntry(ledger_entry::LedgerEntry<'a>), Subscribe(subscribe::Subscribe<'a>), Unsubscribe(unsubscribe::Unsubscribe<'a>), Fee(fee::Fee<'a>), @@ -333,7 +334,7 @@ impl<'a> From> for XRPLRequest<'a> { impl<'a> From> for XRPLRequest<'a> { fn from(request: ledger_entry::LedgerEntry<'a>) -> Self { - XRPLRequest::LedgerEntry(alloc::boxed::Box::new(request)) + XRPLRequest::LedgerEntry(request) } } From 3c8667e1ff349727bc5e6e388e8dbdef6cdc78eb Mon Sep 17 00:00:00 2001 From: e-desouza Date: Wed, 24 Jun 2026 20:18:45 -0400 Subject: [PATCH 13/14] test(domains): merge PD set/delete integration tests into one file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two files shared wallet setup, client init, and helper functions. Merging into permissioned_domain.rs makes the full lifecycle readable as a single file and eliminates duplicated boilerplate. Changes: - Move all tests from permissioned_domain_set.rs and permissioned_domain_delete.rs into tests/transactions/permissioned_domain.rs - Route the delete test through the shared kyc_credential/new_pd_set helpers instead of its inlined PermissionedDomainSet struct literal - Drop the temDISABLED early-return guard from the delete path (PermissionedDomains is always enabled in standalone mode — redundant guard masked test intent) - Add Flags=0 assertion (xrpl.js parity: newly-created PD has Flags=0) - Add ledger_entry deep-equal vs account_objects assertion (xrpl.js parity: assert.deepEqual(pd, ledgerEntryResult.result.node)) - Replace two mod entries in tests/transactions/mod.rs with single pub mod permissioned_domain --- tests/transactions/mod.rs | 3 +- ...d_domain_set.rs => permissioned_domain.rs} | 152 +++++++++++++++++- .../permissioned_domain_delete.rs | 139 ---------------- 3 files changed, 146 insertions(+), 148 deletions(-) rename tests/transactions/{permissioned_domain_set.rs => permissioned_domain.rs} (67%) delete mode 100644 tests/transactions/permissioned_domain_delete.rs diff --git a/tests/transactions/mod.rs b/tests/transactions/mod.rs index 28807b31..5b1e8371 100644 --- a/tests/transactions/mod.rs +++ b/tests/transactions/mod.rs @@ -33,8 +33,7 @@ pub mod payment_channel_claim; pub mod payment_channel_create; pub mod payment_channel_fund; pub mod payment_mpt; -pub mod permissioned_domain_delete; -pub mod permissioned_domain_set; +pub mod permissioned_domain; pub mod set_regular_key; pub mod signer_list_set; pub mod submit_and_wait; diff --git a/tests/transactions/permissioned_domain_set.rs b/tests/transactions/permissioned_domain.rs similarity index 67% rename from tests/transactions/permissioned_domain_set.rs rename to tests/transactions/permissioned_domain.rs index 2c224f5e..a303f2ed 100644 --- a/tests/transactions/permissioned_domain_set.rs +++ b/tests/transactions/permissioned_domain.rs @@ -1,12 +1,14 @@ -// xrpl.js reference: N/A (no dedicated PD integration test in xrpl.js yet) +// xrpl.js reference: packages/xrpl/test/integration/transactions/permissionedDomain.test.ts // rippled reference: src/test/app/PermissionedDomains_test.cpp // // Scenarios: // - base: create a new PermissionedDomain, verify tesSUCCESS -// - account_objects_filter: filter by type=permissioned_domain; verify exactly 1 object -// - ledger_entry_by_index: query domain by its ledger hash +// - account_objects_filter: filter by type=permissioned_domain; verify 1 object, Flags=0, +// non-empty AcceptedCredentials +// - ledger_entry_by_index: query domain by ledger hash; deep-equal vs account_objects node // - ledger_entry_by_account_seq: query domain by owner account + sequence // - update: replace credentials on existing domain (KYC → AML), verify KYC absent +// - delete: full lifecycle (Set → account_objects → Delete → verify gone) use crate::common::{ constants::STANDALONE_URL, generate_funded_wallet, get_client, ledger_accept, @@ -17,9 +19,14 @@ use xrpl::asynch::transaction::sign_and_submit; use xrpl::models::requests::account_objects::{AccountObjectType, AccountObjects}; use xrpl::models::requests::{CommonFields as RequestCommonFields, RequestMethod}; use xrpl::models::results; +use xrpl::models::transactions::permissioned_domain_delete::PermissionedDomainDelete; use xrpl::models::transactions::permissioned_domain_set::PermissionedDomainSet; use xrpl::models::transactions::{CommonFields, Credential, TransactionType}; +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── + fn kyc_credential(issuer: &str) -> Credential { Credential { issuer: issuer.to_string(), @@ -77,6 +84,10 @@ async fn ledger_entry_by_account_seq(account: &str, seq: u64) -> serde_json::Val .await } +// ────────────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────────────── + #[tokio::test] async fn test_permissioned_domain_set_base() { with_blockchain_lock(|| async { @@ -99,6 +110,7 @@ async fn test_permissioned_domain_set_base() { .await; } +/// Mirrors xrpl.js step-2: account_objects filter, Flags=0, AcceptedCredentials non-empty. #[tokio::test] async fn test_permissioned_domain_account_objects_filter() { with_blockchain_lock(|| async { @@ -151,6 +163,13 @@ async fn test_permissioned_domain_account_objects_filter() { assert_eq!(obj["LedgerEntryType"], "PermissionedDomain"); assert_eq!(obj["Owner"], wallet.classic_address.as_str()); + // xrpl.js parity: newly-created PD has Flags = 0 + assert_eq!( + obj["Flags"].as_u64().unwrap_or(u64::MAX), + 0, + "PermissionedDomain Flags should be 0" + ); + let creds = obj["AcceptedCredentials"] .as_array() .expect("AcceptedCredentials must be an array"); @@ -162,6 +181,7 @@ async fn test_permissioned_domain_account_objects_filter() { .await; } +/// Mirrors xrpl.js step-3: ledger_entry by index deep-equals the account_objects node. #[tokio::test] async fn test_permissioned_domain_ledger_entry_by_index() { with_blockchain_lock(|| async { @@ -180,7 +200,7 @@ async fn test_permissioned_domain_ledger_entry_by_index() { assert_eq!(result.engine_result, "tesSUCCESS"); ledger_accept().await; - // Get domain_id from account_objects + // Fetch domain via account_objects let ao_response = client .request( AccountObjects { @@ -208,13 +228,14 @@ async fn test_permissioned_domain_ledger_entry_by_index() { "Expected 1 PermissionedDomain, got {}", ao.account_objects.len() ); - let domain_id = ao.account_objects[0]["index"] + let pd_obj = &ao.account_objects[0]; + let domain_id = pd_obj["index"] .as_str() - .or_else(|| ao.account_objects[0]["LedgerIndex"].as_str()) + .or_else(|| pd_obj["LedgerIndex"].as_str()) .expect("index/LedgerIndex field missing on account_objects[0]") .to_string(); - // Query ledger_entry by index hash (raw RPC — typed client only handles AccountRoot) + // Fetch same domain via ledger_entry by index let entry = ledger_entry_by_index(&domain_id).await; assert_eq!( @@ -227,6 +248,12 @@ async fn test_permissioned_domain_ledger_entry_by_index() { wallet.classic_address.as_str(), "Owner mismatch" ); + + // xrpl.js parity: ledger_entry node must deep-equal the account_objects entry + assert_eq!( + &entry["node"], pd_obj, + "ledger_entry node must match account_objects[0]" + ); }) .await; } @@ -402,3 +429,114 @@ async fn test_permissioned_domain_update_credentials() { }) .await; } + +/// Full lifecycle: PDSet → account_objects (verify 1) → PDDelete → account_objects (verify 0). +/// Mirrors xrpl.js step-3 (delete) in permissionedDomain.test.ts. +#[tokio::test] +async fn test_permissioned_domain_delete_base() { + with_blockchain_lock(|| async { + let client = get_client().await; + let wallet = generate_funded_wallet().await; + + // Create the domain first + let mut set_tx = new_pd_set( + &wallet.classic_address, + None, + vec![kyc_credential(&wallet.classic_address)], + ); + let set_result = sign_and_submit(&mut set_tx, client, &wallet, true, true) + .await + .expect("PermissionedDomainSet submission should not fail"); + + assert_eq!( + set_result.engine_result, "tesSUCCESS", + "PermissionedDomainSet must succeed before delete test: {}", + set_result.engine_result + ); + ledger_accept().await; + + let ao_response = client + .request( + AccountObjects { + common_fields: RequestCommonFields { + command: RequestMethod::AccountObjects, + id: None, + }, + account: wallet.classic_address.clone().into(), + ledger_lookup: None, + r#type: Some(AccountObjectType::PermissionedDomain), + deletion_blockers_only: None, + limit: None, + marker: None, + } + .into(), + ) + .await + .expect("account_objects request should succeed"); + let account_objects: results::account_objects::AccountObjects<'_> = ao_response + .try_into() + .expect("account_objects response should deserialize"); + + assert_eq!( + account_objects.account_objects.len(), + 1, + "Expected 1 PermissionedDomain before delete" + ); + + let domain_id = account_objects.account_objects[0]["index"] + .as_str() + .or_else(|| account_objects.account_objects[0]["LedgerIndex"].as_str()) + .expect("PermissionedDomain object should have an index field") + .to_string(); + + let mut delete_tx = PermissionedDomainDelete { + common_fields: CommonFields { + account: wallet.classic_address.clone().into(), + transaction_type: TransactionType::PermissionedDomainDelete, + ..Default::default() + }, + domain_id: domain_id.into(), + }; + + let delete_result = sign_and_submit(&mut delete_tx, client, &wallet, true, true) + .await + .expect("PermissionedDomainDelete submission should not fail"); + + assert_eq!( + delete_result.engine_result, "tesSUCCESS", + "PermissionedDomainDelete should succeed: {}", + delete_result.engine_result + ); + ledger_accept().await; + + // Verify domain is gone + let ao_after = client + .request( + AccountObjects { + common_fields: RequestCommonFields { + command: RequestMethod::AccountObjects, + id: None, + }, + account: wallet.classic_address.clone().into(), + ledger_lookup: None, + r#type: Some(AccountObjectType::PermissionedDomain), + deletion_blockers_only: None, + limit: None, + marker: None, + } + .into(), + ) + .await + .expect("account_objects after delete should succeed"); + let ao_result: results::account_objects::AccountObjects<'_> = ao_after + .try_into() + .expect("account_objects after delete should deserialize"); + + assert_eq!( + ao_result.account_objects.len(), + 0, + "PermissionedDomain should be absent after deletion" + ); + }) + .await; +} diff --git a/tests/transactions/permissioned_domain_delete.rs b/tests/transactions/permissioned_domain_delete.rs deleted file mode 100644 index c3ac47bf..00000000 --- a/tests/transactions/permissioned_domain_delete.rs +++ /dev/null @@ -1,139 +0,0 @@ -// xrpl.js reference: N/A (XLS-80 is a new feature) -// rippled reference: src/test/app/PermissionedDomains_test.cpp -// -// Scenarios: -// - base: create a PermissionedDomain, delete it, verify it is gone from account_objects - -use crate::common::{generate_funded_wallet, get_client, ledger_accept, with_blockchain_lock}; -use xrpl::asynch::clients::XRPLAsyncClient; -use xrpl::asynch::transaction::sign_and_submit; -use xrpl::models::requests::account_objects::{AccountObjectType, AccountObjects}; -use xrpl::models::requests::{CommonFields as RequestCommonFields, RequestMethod}; -use xrpl::models::results; -use xrpl::models::transactions::permissioned_domain_delete::PermissionedDomainDelete; -use xrpl::models::transactions::permissioned_domain_set::PermissionedDomainSet; -use xrpl::models::transactions::{CommonFields, Credential, TransactionType}; - -#[tokio::test] -async fn test_permissioned_domain_delete_base() { - with_blockchain_lock(|| async { - let client = get_client().await; - let wallet = generate_funded_wallet().await; - - let mut set_tx = PermissionedDomainSet { - common_fields: CommonFields { - account: wallet.classic_address.clone().into(), - transaction_type: TransactionType::PermissionedDomainSet, - ..Default::default() - }, - domain_id: None, - accepted_credentials: vec![Credential { - issuer: wallet.classic_address.clone(), - credential_type: "4B5943".to_string(), // hex("KYC") - }], - }; - - let set_result = sign_and_submit(&mut set_tx, client, &wallet, true, true) - .await - .expect("PermissionedDomainSet submission should not fail"); - - if set_result.engine_result == "temDISABLED" { - eprintln!("[SKIP] PermissionedDomains amendment disabled — test vacuous"); - ledger_accept().await; - return; - } - - assert_eq!( - set_result.engine_result, "tesSUCCESS", - "PermissionedDomainSet must succeed before delete test: {}", - set_result.engine_result - ); - ledger_accept().await; - - let ao_response = client - .request( - AccountObjects { - common_fields: RequestCommonFields { - command: RequestMethod::AccountObjects, - id: None, - }, - account: wallet.classic_address.clone().into(), - ledger_lookup: None, - r#type: Some(AccountObjectType::PermissionedDomain), - deletion_blockers_only: None, - limit: None, - marker: None, - } - .into(), - ) - .await - .expect("account_objects request should succeed"); - let account_objects: results::account_objects::AccountObjects<'_> = ao_response - .try_into() - .expect("account_objects response should deserialize"); - - assert_eq!( - account_objects.account_objects.len(), - 1, - "Expected 1 PermissionedDomain before delete" - ); - - let domain_id = account_objects - .account_objects - .iter() - .find(|o| o["LedgerEntryType"] == "PermissionedDomain") - .and_then(|o| o["index"].as_str().or_else(|| o["LedgerIndex"].as_str())) - .expect("PermissionedDomain object should have an index field") - .to_string(); - - let mut delete_tx = PermissionedDomainDelete { - common_fields: CommonFields { - account: wallet.classic_address.clone().into(), - transaction_type: TransactionType::PermissionedDomainDelete, - ..Default::default() - }, - domain_id: domain_id.into(), - }; - - let delete_result = sign_and_submit(&mut delete_tx, client, &wallet, true, true) - .await - .expect("PermissionedDomainDelete submission should not fail"); - - assert_eq!( - delete_result.engine_result, "tesSUCCESS", - "PermissionedDomainDelete should succeed: {}", - delete_result.engine_result - ); - ledger_accept().await; - - // Verify domain is gone - let ao_after = client - .request( - AccountObjects { - common_fields: RequestCommonFields { - command: RequestMethod::AccountObjects, - id: None, - }, - account: wallet.classic_address.clone().into(), - ledger_lookup: None, - r#type: Some(AccountObjectType::PermissionedDomain), - deletion_blockers_only: None, - limit: None, - marker: None, - } - .into(), - ) - .await - .expect("account_objects after delete should succeed"); - let ao_result: results::account_objects::AccountObjects<'_> = ao_after - .try_into() - .expect("account_objects after delete should deserialize"); - - assert_eq!( - ao_result.account_objects.len(), - 0, - "PermissionedDomain should be absent after deletion" - ); - }) - .await; -} From a0f5fc14e97c20e5900cb9303aacd36ed30b3aed Mon Sep 17 00:00:00 2001 From: e-desouza Date: Fri, 26 Jun 2026 12:39:26 -0400 Subject: [PATCH 14/14] refactor(domains): address review findings on PermissionedDomain validation Follow-up to the multi-stream review of the XLS-80 PermissionedDomain models. - Replace the with_credential builder's assert! (panic on the 11th credential) with unconditional push; the 1..=10 bound is already enforced non-fatally by get_errors, so a library builder no longer aborts on caller input. - Extract validate_accepted_credentials (count bound + per-credential validity + case-insensitive duplicate detection) and call it from both PermissionedDomainSet and the PermissionedDomain ledger object so the two validate under identical rules (the ledger object previously skipped the duplicate check). - Reuse validate_domain_id in PermissionedDomainDelete instead of duplicating the len/hex/non-zero check inline, keeping the rule in lockstep with PermissionedDomainSet. - Add the missing unit coverage: ledger-object get_errors (valid, invalid owner, empty list, duplicate, over-limit), case-insensitive credential dedup (4b5943 vs 4B5943), and DomainID wrong-length / non-hex rejection on the Set path. --- .../ledger/objects/permissioned_domain.rs | 106 ++++++-- .../permissioned_domain_delete.rs | 59 ++--- .../transactions/permissioned_domain_set.rs | 249 +++++++++++------- 3 files changed, 272 insertions(+), 142 deletions(-) diff --git a/src/models/ledger/objects/permissioned_domain.rs b/src/models/ledger/objects/permissioned_domain.rs index 0aadc242..4f124c1c 100644 --- a/src/models/ledger/objects/permissioned_domain.rs +++ b/src/models/ledger/objects/permissioned_domain.rs @@ -54,7 +54,7 @@ impl<'a> crate::models::Model for PermissionedDomain<'a> { fn get_errors(&self) -> crate::models::XRPLModelResult<()> { use crate::core::addresscodec::is_valid_classic_address; use crate::models::exceptions::XRPLModelException; - use crate::models::transactions::permissioned_domain_set::validate_credential; + use crate::models::transactions::permissioned_domain_set::validate_accepted_credentials; if !is_valid_classic_address(&self.owner) { return Err(XRPLModelException::InvalidValue { @@ -63,22 +63,9 @@ impl<'a> crate::models::Model for PermissionedDomain<'a> { found: self.owner.clone().into_owned(), }); } - if self.accepted_credentials.is_empty() { - return Err(XRPLModelException::MissingField( - "AcceptedCredentials".into(), - )); - } - if self.accepted_credentials.len() > 10 { - return Err(XRPLModelException::ValueTooLong { - field: "AcceptedCredentials".into(), - max: 10, - found: self.accepted_credentials.len(), - }); - } - for credential in &self.accepted_credentials { - validate_credential(credential)?; - } - Ok(()) + // Same credential-list rules as PermissionedDomainSet (1..=10, valid, no dupes) + // so the ledger object and its originating transaction validate identically. + validate_accepted_credentials(&self.accepted_credentials) } } @@ -117,6 +104,9 @@ mod test_serde { use alloc::string::ToString; use alloc::vec; + /// Shared test owner / credential issuer (a valid classic address). + const TEST_ACCOUNT: &str = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"; + #[test] fn test_serialize() { let domain = PermissionedDomain { @@ -126,7 +116,7 @@ mod test_serde { index: Some(Cow::from("ForTest")), ledger_index: None, }, - owner: Cow::from("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"), + owner: Cow::from(TEST_ACCOUNT), accepted_credentials: vec![ Credential { issuer: "rIssuerA".to_string(), @@ -225,4 +215,84 @@ mod test_serde { Some(Cow::from("TestLedgerIndex")) ); } + + use crate::models::exceptions::XRPLModelException; + use crate::models::Model; + + /// A valid PermissionedDomain ledger object (real classic addresses) for validation tests. + fn valid_domain(credentials: Vec) -> PermissionedDomain<'static> { + PermissionedDomain { + common_fields: CommonFields { + flags: FlagCollection::default(), + ledger_entry_type: LedgerEntryType::PermissionedDomain, + index: None, + ledger_index: None, + }, + owner: Cow::from(TEST_ACCOUNT), + accepted_credentials: credentials, + sequence: 1, + owner_node: Cow::from("0"), + previous_txn_id: Cow::from( + "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2", + ), + previous_txn_lgr_seq: 1000, + } + } + + fn kyc() -> Credential { + Credential { + issuer: TEST_ACCOUNT.to_string(), + credential_type: "4B5943".to_string(), + } + } + + #[test] + fn test_get_errors_valid() { + assert!(valid_domain(vec![kyc()]).get_errors().is_ok()); + } + + #[test] + fn test_get_errors_invalid_owner_rejected() { + let mut domain = valid_domain(vec![kyc()]); + domain.owner = Cow::from("not-an-address"); + assert!(matches!( + domain.get_errors(), + Err(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_get_errors_empty_credentials_rejected() { + assert!(matches!( + valid_domain(vec![]).get_errors(), + Err(XRPLModelException::MissingField(_)) + )); + } + + #[test] + fn test_get_errors_duplicate_credentials_rejected() { + // Shares the transaction's dedup rule (case-insensitive CredentialType). + let dup_lower = Credential { + issuer: TEST_ACCOUNT.to_string(), + credential_type: "4b5943".to_string(), + }; + assert!(matches!( + valid_domain(vec![kyc(), dup_lower]).get_errors(), + Err(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_get_errors_too_many_credentials_rejected() { + let creds: Vec = (0..11) + .map(|i| Credential { + issuer: TEST_ACCOUNT.to_string(), + credential_type: alloc::format!("{:06X}", i), + }) + .collect(); + assert!(matches!( + valid_domain(creds).get_errors(), + Err(XRPLModelException::ValueTooLong { .. }) + )); + } } diff --git a/src/models/transactions/permissioned_domain_delete.rs b/src/models/transactions/permissioned_domain_delete.rs index c7b388c7..cea10dd9 100644 --- a/src/models/transactions/permissioned_domain_delete.rs +++ b/src/models/transactions/permissioned_domain_delete.rs @@ -4,7 +4,6 @@ use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use crate::models::amount::XRPAmount; -use crate::models::exceptions::XRPLModelException; use crate::models::{ transactions::{Memo, Signer, Transaction, TransactionType}, Model, ValidateCurrencies, @@ -45,18 +44,11 @@ pub struct PermissionedDomainDelete<'a> { impl<'a> Model for PermissionedDomainDelete<'a> { fn get_errors(&self) -> crate::models::XRPLModelResult<()> { // DomainID is the 32-byte hash of the PermissionedDomain ledger entry, - // serialized as 64 uppercase hex chars. - let domain_id = self.domain_id.as_ref(); - if domain_id.len() != 64 - || !domain_id.chars().all(|c| c.is_ascii_hexdigit()) - || domain_id.chars().all(|c| c == '0') - { - return Err(XRPLModelException::InvalidValue { - field: "DomainID".into(), - expected: "non-zero 64-character hex string".into(), - found: domain_id.into(), - }); - } + // serialized as 64 uppercase hex chars. Reuse the shared validator so the + // rule stays in lockstep with PermissionedDomainSet. + crate::models::transactions::permissioned_domain_set::validate_domain_id( + self.domain_id.as_ref(), + )?; self.validate_currencies() } } @@ -121,12 +113,16 @@ impl<'a> PermissionedDomainDelete<'a> { #[cfg(test)] mod tests { use super::*; + use crate::models::exceptions::XRPLModelException; + + /// Shared test account (a valid classic address). + const TEST_ACCOUNT: &str = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"; #[test] fn test_serde() { let txn = PermissionedDomainDelete { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainDelete, fee: Some("10".into()), sequence: Some(1), @@ -150,7 +146,7 @@ mod tests { fn test_serde_json_format() { let txn = PermissionedDomainDelete { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainDelete, fee: Some("12".into()), sequence: Some(5), @@ -176,7 +172,7 @@ mod tests { fn test_builder_pattern() { let txn = PermissionedDomainDelete { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainDelete, ..Default::default() }, @@ -192,10 +188,7 @@ mod tests { memo_type: Some("text".into()), }); - assert_eq!( - txn.common_fields.account, - "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" - ); + assert_eq!(txn.common_fields.account, TEST_ACCOUNT); 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)); @@ -211,17 +204,14 @@ mod tests { fn test_default() { let txn = PermissionedDomainDelete { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainDelete, ..Default::default() }, domain_id: "AABB00112233445566778899AABB00112233445566778899AABB00112233445A".into(), }; - assert_eq!( - txn.common_fields.account, - "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" - ); + assert_eq!(txn.common_fields.account, TEST_ACCOUNT); assert_eq!( txn.common_fields.transaction_type, TransactionType::PermissionedDomainDelete @@ -237,7 +227,7 @@ mod tests { #[test] fn test_new_constructor() { let txn = PermissionedDomainDelete::new( - "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + TEST_ACCOUNT.into(), None, Some("12".into()), Some(596447), @@ -249,10 +239,7 @@ mod tests { "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2".into(), ); - assert_eq!( - txn.common_fields.account, - "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" - ); + assert_eq!(txn.common_fields.account, TEST_ACCOUNT); assert_eq!( txn.common_fields.transaction_type, TransactionType::PermissionedDomainDelete @@ -270,7 +257,7 @@ mod tests { fn test_ticket_sequence() { let txn = PermissionedDomainDelete { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainDelete, ..Default::default() }, @@ -287,7 +274,7 @@ mod tests { fn test_account_txn_id() { let txn = PermissionedDomainDelete { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainDelete, ..Default::default() }, @@ -308,7 +295,7 @@ mod tests { // DomainID must be exactly 64 hex chars (32-byte hash). let too_short = PermissionedDomainDelete { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainDelete, ..Default::default() }, @@ -322,7 +309,7 @@ mod tests { // Correct length but non-hex chars. let non_hex = PermissionedDomainDelete { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainDelete, ..Default::default() }, @@ -338,7 +325,7 @@ mod tests { fn test_delete_all_zero_domain_id_rejected() { let txn = PermissionedDomainDelete { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainDelete, ..Default::default() }, @@ -355,7 +342,7 @@ mod tests { fn test_valid_domain_id_accepted() { let txn = PermissionedDomainDelete { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainDelete, ..Default::default() }, diff --git a/src/models/transactions/permissioned_domain_set.rs b/src/models/transactions/permissioned_domain_set.rs index 35036d36..60315278 100644 --- a/src/models/transactions/permissioned_domain_set.rs +++ b/src/models/transactions/permissioned_domain_set.rs @@ -55,37 +55,7 @@ pub struct PermissionedDomainSet<'a> { impl<'a> Model for PermissionedDomainSet<'a> { fn get_errors(&self) -> crate::models::XRPLModelResult<()> { - // XLS-80 mandates AcceptedCredentials contain between 1 and 10 entries. - if self.accepted_credentials.is_empty() { - return Err(XRPLModelException::MissingField( - "AcceptedCredentials".into(), - )); - } - if self.accepted_credentials.len() > 10 { - return Err(XRPLModelException::ValueTooLong { - field: "AcceptedCredentials".into(), - max: 10, - found: self.accepted_credentials.len(), - }); - } - let mut seen: BTreeSet<(alloc::string::String, alloc::string::String)> = BTreeSet::new(); - for credential in &self.accepted_credentials { - validate_credential(credential)?; - // Normalise CredentialType to uppercase hex before duplicate check so that - // "4b5943" and "4B5943" are treated as the same credential (rippled decodes - // the blob bytes and hashes them; casing is irrelevant at the wire level). - let key = ( - credential.issuer.clone(), - credential.credential_type.to_uppercase(), - ); - if !seen.insert(key) { - return Err(XRPLModelException::InvalidValue { - field: "AcceptedCredentials".into(), - expected: "unique Issuer/CredentialType pairs".into(), - found: alloc::format!("{}/{}", credential.issuer, credential.credential_type), - }); - } - } + validate_accepted_credentials(&self.accepted_credentials)?; if let Some(domain_id) = &self.domain_id { validate_domain_id(domain_id.as_ref())?; } @@ -93,6 +63,47 @@ impl<'a> Model for PermissionedDomainSet<'a> { } } +/// Validates an `AcceptedCredentials` list per XLS-80: it must contain between 1 and 10 +/// entries, each a valid [`Credential`], with no duplicate `(Issuer, CredentialType)` pairs. +/// `CredentialType` is compared case-insensitively because rippled decodes the blob bytes and +/// hashes them, so hex casing is irrelevant at the wire level; `Issuer` is a base58 classic +/// address (validated by [`validate_credential`]) and is compared verbatim. +/// +/// Shared by both [`PermissionedDomainSet`] (outbound transaction) and the +/// `PermissionedDomain` ledger object so the two validate under identical rules. +pub(crate) fn validate_accepted_credentials( + credentials: &[Credential], +) -> crate::models::XRPLModelResult<()> { + if credentials.is_empty() { + return Err(XRPLModelException::MissingField( + "AcceptedCredentials".into(), + )); + } + if credentials.len() > 10 { + return Err(XRPLModelException::ValueTooLong { + field: "AcceptedCredentials".into(), + max: 10, + found: credentials.len(), + }); + } + let mut seen: BTreeSet<(alloc::string::String, alloc::string::String)> = BTreeSet::new(); + for credential in credentials { + validate_credential(credential)?; + let key = ( + credential.issuer.clone(), + credential.credential_type.to_uppercase(), + ); + if !seen.insert(key) { + return Err(XRPLModelException::InvalidValue { + field: "AcceptedCredentials".into(), + expected: "unique Issuer/CredentialType pairs".into(), + found: alloc::format!("{}/{}", credential.issuer, credential.credential_type), + }); + } + } + Ok(()) +} + /// Validates a DomainID per XLS-80: must be a non-zero 64-character hex string /// (the 32-byte hash of the PermissionedDomain ledger entry, serialized as uppercase hex). pub(crate) fn validate_domain_id(domain_id: &str) -> crate::models::XRPLModelResult<()> { @@ -218,11 +229,11 @@ impl<'a> PermissionedDomainSet<'a> { } /// Add a single credential to the accepted credentials list. + /// + /// The 1..=10 bound is enforced by [`Model::get_errors`], not here — the builder + /// accumulates freely and validation surfaces an over-limit list as a recoverable + /// `XRPLModelException::ValueTooLong` rather than panicking. pub fn with_credential(mut self, credential: Credential) -> Self { - assert!( - self.accepted_credentials.len() < 10, - "AcceptedCredentials exceeds XLS-80 maximum of 10 entries" - ); self.accepted_credentials.push(credential); self } @@ -234,11 +245,14 @@ mod tests { use alloc::string::ToString; use alloc::vec; + /// Shared test account / credential issuer (a valid classic address). + const TEST_ACCOUNT: &str = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"; + #[test] fn test_serde() { let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, fee: Some("10".into()), sequence: Some(1), @@ -247,7 +261,7 @@ mod tests { }, domain_id: None, accepted_credentials: vec![Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: "4B5943".to_string(), // hex("KYC") }], }; @@ -261,7 +275,7 @@ mod tests { fn test_serde_with_domain_id() { let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, fee: Some("10".into()), sequence: Some(2), @@ -272,7 +286,7 @@ mod tests { "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2".into(), ), accepted_credentials: vec![Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: "414D4C".to_string(), // hex("AML") }], }; @@ -293,7 +307,7 @@ mod tests { fn test_builder_pattern() { let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, ..Default::default() }, @@ -304,14 +318,11 @@ mod tests { .with_last_ledger_sequence(596447) .with_source_tag(42) .with_credential(Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: "4B5943".to_string(), // hex("KYC") }); - assert_eq!( - txn.common_fields.account, - "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" - ); + assert_eq!(txn.common_fields.account, TEST_ACCOUNT); 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)); @@ -324,17 +335,14 @@ mod tests { fn test_default() { let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, ..Default::default() }, ..Default::default() }; - assert_eq!( - txn.common_fields.account, - "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" - ); + assert_eq!(txn.common_fields.account, TEST_ACCOUNT); assert_eq!( txn.common_fields.transaction_type, TransactionType::PermissionedDomainSet @@ -351,7 +359,7 @@ mod tests { fn test_with_credentials() { let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, fee: Some("10".into()), sequence: Some(5), @@ -360,25 +368,22 @@ mod tests { domain_id: None, accepted_credentials: vec![ Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: "4B5943".to_string(), // hex("KYC") }, Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: "414D4C".to_string(), // hex("AML") }, Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: "41434352454449544544".to_string(), // hex("ACCREDITED") }, ], }; assert_eq!(txn.accepted_credentials.len(), 3); - assert_eq!( - txn.accepted_credentials[0].issuer, - "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string() - ); + assert_eq!(txn.accepted_credentials[0].issuer, TEST_ACCOUNT.to_string()); assert_eq!( txn.accepted_credentials[1].credential_type, "414D4C".to_string() @@ -395,7 +400,7 @@ mod tests { "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2".to_string(); let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, fee: Some("10".into()), sequence: Some(10), @@ -416,7 +421,7 @@ mod tests { fn test_create_domain() { let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, fee: Some("10".into()), sequence: Some(1), @@ -424,7 +429,7 @@ mod tests { }, domain_id: None, accepted_credentials: vec![Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: "4B5943".to_string(), // hex("KYC") }], }; @@ -437,7 +442,7 @@ mod tests { #[test] fn test_new_constructor() { let txn = PermissionedDomainSet::new( - "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + TEST_ACCOUNT.into(), None, Some("12".into()), Some(596447), @@ -448,15 +453,12 @@ mod tests { None, None, vec![Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: "4B5943".to_string(), // hex("KYC") }], ); - assert_eq!( - txn.common_fields.account, - "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" - ); + assert_eq!(txn.common_fields.account, TEST_ACCOUNT); assert_eq!( txn.common_fields.transaction_type, TransactionType::PermissionedDomainSet @@ -472,7 +474,7 @@ mod tests { fn test_with_domain_id_builder() { let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, ..Default::default() }, @@ -480,7 +482,7 @@ mod tests { } .with_domain_id("AABB0011".into()) .with_accepted_credentials(vec![Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: "4B5943".to_string(), // hex("KYC") }]); @@ -492,7 +494,7 @@ mod tests { fn test_with_memo() { let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, ..Default::default() }, @@ -506,7 +508,7 @@ mod tests { memo_type: Some("text".into()), }) .with_credential(Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: "4B5943".to_string(), // hex("KYC") }); @@ -519,7 +521,7 @@ mod tests { // XLS-80 mandates AcceptedCredentials has 1..=10 entries; empty must fail validation. let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, fee: Some("10".into()), sequence: Some(1), @@ -542,13 +544,13 @@ mod tests { // XLS-80 caps AcceptedCredentials at 10 entries. let credentials: Vec = (0..11) .map(|_| Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: "4B5943".to_string(), }) .collect(); let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, ..Default::default() }, @@ -573,13 +575,13 @@ mod tests { // CredentialType is an sfBlob; non-hex values must fail validation. let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, ..Default::default() }, domain_id: None, accepted_credentials: vec![Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: "KYC".to_string(), // not hex }], }; @@ -595,7 +597,7 @@ mod tests { fn test_credential_type_64_bytes_accepted() { // 64 bytes hex-encoded = 128 chars; this is the rippled maximum. let credential = Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: "A".repeat(128), }; @@ -607,13 +609,13 @@ mod tests { let too_long = "A".repeat(130); let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, ..Default::default() }, domain_id: None, accepted_credentials: vec![Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: too_long, }], }; @@ -628,12 +630,12 @@ mod tests { #[test] fn test_duplicate_credentials_rejected() { let duplicate = Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: "4B5943".to_string(), }; let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, ..Default::default() }, @@ -651,13 +653,84 @@ mod tests { fn test_set_all_zero_domain_id_rejected() { let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, ..Default::default() }, domain_id: Some("0".repeat(64).into()), accepted_credentials: vec![Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), + credential_type: "4B5943".to_string(), + }], + }; + + assert!(matches!( + txn.get_errors(), + Err(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_duplicate_credentials_case_insensitive_rejected() { + // "4b5943" and "4B5943" are the same credential on the wire — dedup must + // collapse them via the to_uppercase() normalization in validate_accepted_credentials. + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + domain_id: None, + accepted_credentials: vec![ + Credential { + issuer: TEST_ACCOUNT.to_string(), + credential_type: "4b5943".to_string(), + }, + Credential { + issuer: TEST_ACCOUNT.to_string(), + credential_type: "4B5943".to_string(), + }, + ], + }; + + assert!(matches!( + txn.get_errors(), + Err(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_set_wrong_length_domain_id_rejected() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + domain_id: Some("ABCD".into()), // 4 chars, not 64 + accepted_credentials: vec![Credential { + issuer: TEST_ACCOUNT.to_string(), + credential_type: "4B5943".to_string(), + }], + }; + + assert!(matches!( + txn.get_errors(), + Err(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_set_non_hex_domain_id_rejected() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + domain_id: Some("G".repeat(64).into()), // 64 chars but 'G' is not hex + accepted_credentials: vec![Credential { + issuer: TEST_ACCOUNT.to_string(), credential_type: "4B5943".to_string(), }], }; @@ -672,7 +745,7 @@ mod tests { fn test_ticket_sequence() { let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, ..Default::default() }, @@ -681,7 +754,7 @@ mod tests { .with_ticket_sequence(42) .with_fee("10".into()) .with_credential(Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: "4B5943".to_string(), // hex("KYC") }); @@ -693,7 +766,7 @@ mod tests { fn test_credential_empty_issuer_rejected() { let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, ..Default::default() }, @@ -716,13 +789,13 @@ mod tests { fn test_credential_empty_credential_type_rejected() { let txn = PermissionedDomainSet { common_fields: CommonFields { - account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + account: TEST_ACCOUNT.into(), transaction_type: TransactionType::PermissionedDomainSet, ..Default::default() }, domain_id: None, accepted_credentials: vec![Credential { - issuer: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".to_string(), + issuer: TEST_ACCOUNT.to_string(), credential_type: "".to_string(), }], };