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 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..4f124c1c --- /dev/null +++ b/src/models/ledger/objects/permissioned_domain.rs @@ -0,0 +1,298 @@ +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> 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_accepted_credentials; + + 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(), + }); + } + // 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) + } +} + +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; + + /// Shared test owner / credential issuer (a valid classic address). + const TEST_ACCOUNT: &str = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"; + + #[test] + fn test_serialize() { + 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(TEST_ACCOUNT), + accepted_credentials: 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") + }, + ], + 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(); + + // 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 { + 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(), + }], + 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(), + LedgerEntryType::PermissionedDomain + ); + } + + #[test] + fn test_fields() { + 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") + }], + 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); + 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")) + ); + } + + 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/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, diff --git a/src/models/requests/ledger_entry.rs b/src/models/requests/ledger_entry.rs index 708fb033..10faa288 100644 --- a/src/models/requests/ledger_entry.rs +++ b/src/models/requests/ledger_entry.rs @@ -41,6 +41,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: u32, +} + +/// 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 +114,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 +158,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 +176,11 @@ impl<'a> LedgerEntryError for LedgerEntry<'a> { "escrow", "payment_channel", "deposit_preauth", + "permissioned_domain", "ticket", - ])) - } else { - Ok(()) + ])); } + Ok(()) } } @@ -201,6 +221,7 @@ impl<'a> LedgerEntry<'a> { account_root, check, payment_channel, + permissioned_domain: None, deposit_preauth, directory, escrow, @@ -215,6 +236,38 @@ 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 { + common_fields: CommonFields { + command: RequestMethod::LedgerEntry, + id, + }, + 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, + }), + } + } } pub trait LedgerEntryError { @@ -226,7 +279,7 @@ pub trait LedgerEntryError { mod test_ledger_entry_errors { use super::Offer; use crate::models::Model; - use alloc::string::ToString; + use alloc::format; use super::*; @@ -252,22 +305,21 @@ 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", "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" - ); + assert_eq!(ledger_entry.validate().unwrap_err(), expected); } #[test] @@ -298,4 +350,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..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> { 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 diff --git a/src/models/transactions/permissioned_domain_delete.rs b/src/models/transactions/permissioned_domain_delete.rs new file mode 100644 index 00000000..cea10dd9 --- /dev/null +++ b/src/models/transactions/permissioned_domain_delete.rs @@ -0,0 +1,353 @@ +use alloc::borrow::Cow; +use alloc::vec::Vec; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::models::amount::XRPAmount; +use crate::models::{ + transactions::{Memo, Signer, Transaction, TransactionType}, + Model, ValidateCurrencies, +}; +use crate::models::{FlagCollection, NoFlags}; + +use super::{CommonFields, CommonTransactionBuilder}; + +/// A PermissionedDomainDelete transaction removes an existing permissioned +/// domain from the XRP Ledger. Only the owner of the domain can delete it. +/// +/// See XLS-80 PermissionedDomains: +/// `` +#[skip_serializing_none] +#[derive( + Debug, + Default, + Serialize, + Deserialize, + PartialEq, + Eq, + Clone, + xrpl_rust_macros::ValidateCurrencies, +)] +#[serde(rename_all = "PascalCase")] +pub struct PermissionedDomainDelete<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The ID of the permissioned domain to delete. + #[serde(rename = "DomainID")] + pub domain_id: Cow<'a, str>, +} + +impl<'a> Model for PermissionedDomainDelete<'a> { + fn get_errors(&self) -> crate::models::XRPLModelResult<()> { + // DomainID is the 32-byte hash of the PermissionedDomain ledger entry, + // 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() + } +} + +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 { + account, + transaction_type: TransactionType::PermissionedDomainDelete, + account_txn_id, + fee, + flags: FlagCollection::default(), + last_ledger_sequence, + memos, + sequence, + signers, + source_tag, + ticket_sequence, + ..Default::default() + }, + domain_id, + } + } +} + +#[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: TEST_ACCOUNT.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: TEST_ACCOUNT.into(), + transaction_type: TransactionType::PermissionedDomainDelete, + fee: Some("12".into()), + sequence: Some(5), + signing_pub_key: Some("".into()), + ..Default::default() + }, + domain_id: "AABB00112233445566778899AABB00112233445566778899AABB001122334455".into(), + }; + + 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(); + 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: TEST_ACCOUNT.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, 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)); + 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: TEST_ACCOUNT.into(), + transaction_type: TransactionType::PermissionedDomainDelete, + ..Default::default() + }, + domain_id: "AABB00112233445566778899AABB00112233445566778899AABB00112233445A".into(), + }; + + assert_eq!(txn.common_fields.account, TEST_ACCOUNT); + 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( + TEST_ACCOUNT.into(), + None, + Some("12".into()), + Some(596447), + None, + Some(1), + None, + None, + None, + "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2".into(), + ); + + assert_eq!(txn.common_fields.account, TEST_ACCOUNT); + 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: TEST_ACCOUNT.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: TEST_ACCOUNT.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: TEST_ACCOUNT.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: TEST_ACCOUNT.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: TEST_ACCOUNT.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: TEST_ACCOUNT.into(), + transaction_type: TransactionType::PermissionedDomainDelete, + ..Default::default() + }, + domain_id: "A".repeat(64).into(), + }; + assert!(txn.get_errors().is_ok()); + } +} diff --git a/src/models/transactions/permissioned_domain_set.rs b/src/models/transactions/permissioned_domain_set.rs new file mode 100644 index 00000000..60315278 --- /dev/null +++ b/src/models/transactions/permissioned_domain_set.rs @@ -0,0 +1,810 @@ +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<()> { + validate_accepted_credentials(&self.accepted_credentials)?; + if let Some(domain_id) = &self.domain_id { + validate_domain_id(domain_id.as_ref())?; + } + self.validate_currencies() + } +} + +/// 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<()> { + 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(()) +} + +/// 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())); + } + 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 { + account, + transaction_type: TransactionType::PermissionedDomainSet, + account_txn_id, + fee, + flags: FlagCollection::default(), + last_ledger_sequence, + memos, + sequence, + signers, + source_tag, + ticket_sequence, + ..Default::default() + }, + 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. + /// + /// 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 { + self.accepted_credentials.push(credential); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + 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: TEST_ACCOUNT.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: TEST_ACCOUNT.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: TEST_ACCOUNT.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: TEST_ACCOUNT.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: TEST_ACCOUNT.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: TEST_ACCOUNT.to_string(), + credential_type: "4B5943".to_string(), // hex("KYC") + }); + + 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)); + 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: TEST_ACCOUNT.into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + ..Default::default() + }; + + assert_eq!(txn.common_fields.account, TEST_ACCOUNT); + 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()); + // Empty accepted_credentials violates XLS-80 mandated 1..=10 entries. + assert!(txn.get_errors().is_err()); + } + + #[test] + fn test_with_credentials() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::PermissionedDomainSet, + fee: Some("10".into()), + sequence: Some(5), + ..Default::default() + }, + domain_id: None, + accepted_credentials: vec![ + Credential { + issuer: TEST_ACCOUNT.to_string(), + credential_type: "4B5943".to_string(), // hex("KYC") + }, + Credential { + issuer: TEST_ACCOUNT.to_string(), + credential_type: "414D4C".to_string(), // hex("AML") + }, + Credential { + 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, TEST_ACCOUNT.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: TEST_ACCOUNT.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: TEST_ACCOUNT.into(), + transaction_type: TransactionType::PermissionedDomainSet, + fee: Some("10".into()), + sequence: Some(1), + ..Default::default() + }, + domain_id: None, + accepted_credentials: vec![Credential { + issuer: TEST_ACCOUNT.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( + TEST_ACCOUNT.into(), + None, + Some("12".into()), + Some(596447), + None, + Some(1), + None, + None, + None, + None, + vec![Credential { + issuer: TEST_ACCOUNT.to_string(), + credential_type: "4B5943".to_string(), // hex("KYC") + }], + ); + + assert_eq!(txn.common_fields.account, TEST_ACCOUNT); + 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: TEST_ACCOUNT.into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + ..Default::default() + } + .with_domain_id("AABB0011".into()) + .with_accepted_credentials(vec![Credential { + issuer: TEST_ACCOUNT.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: TEST_ACCOUNT.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: TEST_ACCOUNT.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: TEST_ACCOUNT.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: TEST_ACCOUNT.to_string(), + credential_type: "4B5943".to_string(), + }) + .collect(); + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.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: TEST_ACCOUNT.into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + domain_id: None, + accepted_credentials: vec![Credential { + issuer: TEST_ACCOUNT.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: TEST_ACCOUNT.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: TEST_ACCOUNT.into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + domain_id: None, + accepted_credentials: vec![Credential { + issuer: TEST_ACCOUNT.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: TEST_ACCOUNT.to_string(), + credential_type: "4B5943".to_string(), + }; + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.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: TEST_ACCOUNT.into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + domain_id: Some("0".repeat(64).into()), + 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_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(), + }], + }; + + assert!(matches!( + txn.get_errors(), + Err(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_ticket_sequence() { + let txn = PermissionedDomainSet { + common_fields: CommonFields { + account: TEST_ACCOUNT.into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + ..Default::default() + } + .with_ticket_sequence(42) + .with_fee("10".into()) + .with_credential(Credential { + issuer: TEST_ACCOUNT.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: TEST_ACCOUNT.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: TEST_ACCOUNT.into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + domain_id: None, + accepted_credentials: vec![Credential { + issuer: TEST_ACCOUNT.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()) + ); + } +} diff --git a/tests/transactions/mod.rs b/tests/transactions/mod.rs index f5a9bfd7..5b1e8371 100644 --- a/tests/transactions/mod.rs +++ b/tests/transactions/mod.rs @@ -33,6 +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; pub mod set_regular_key; pub mod signer_list_set; pub mod submit_and_wait; diff --git a/tests/transactions/permissioned_domain.rs b/tests/transactions/permissioned_domain.rs new file mode 100644 index 00000000..a303f2ed --- /dev/null +++ b/tests/transactions/permissioned_domain.rs @@ -0,0 +1,542 @@ +// 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 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, + 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}; + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── + +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 +} + +// ────────────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────────────── + +#[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"); + + assert_eq!(result.engine_result, "tesSUCCESS"); + ledger_accept().await; + }) + .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 { + 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"); + + assert_eq!(result.engine_result, "tesSUCCESS"); + 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 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()); + + // 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"); + assert!( + !creds.is_empty(), + "AcceptedCredentials must not be empty on a valid domain" + ); + }) + .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 { + 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"); + + assert_eq!(result.engine_result, "tesSUCCESS"); + ledger_accept().await; + + // Fetch domain via account_objects + 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 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 pd_obj = &ao.account_objects[0]; + let domain_id = pd_obj["index"] + .as_str() + .or_else(|| pd_obj["LedgerIndex"].as_str()) + .expect("index/LedgerIndex field missing on account_objects[0]") + .to_string(); + + // Fetch same domain via ledger_entry by index + 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" + ); + + // 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; +} + +#[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"); + + assert_eq!(result.engine_result, "tesSUCCESS"); + ledger_accept().await; + + // Get sequence from account_objects + 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 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"); + + assert_eq!(result.engine_result, "tesSUCCESS"); + ledger_accept().await; + + // Get domain_id + 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 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; +} + +/// 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; +}