Skip to main content

xrpl/models/transactions/
permissioned_domain_set.rs

1use alloc::borrow::Cow;
2use alloc::collections::BTreeSet;
3use alloc::vec::Vec;
4use serde::{Deserialize, Serialize};
5use serde_with::skip_serializing_none;
6
7use crate::core::addresscodec::is_valid_classic_address;
8use crate::models::amount::XRPAmount;
9use crate::models::exceptions::XRPLModelException;
10use crate::models::{
11    transactions::{Credential, Memo, Signer, Transaction, TransactionType},
12    Model, ValidateCurrencies,
13};
14use crate::models::{FlagCollection, NoFlags};
15
16use super::{CommonFields, CommonTransactionBuilder};
17
18/// A PermissionedDomainSet transaction creates or updates a permissioned
19/// domain on the XRP Ledger. A permissioned domain defines a set of
20/// accepted credentials that grant access to restricted functionality.
21///
22/// When `domain_id` is `None`, a new domain is created. When `domain_id`
23/// is provided, the existing domain is updated with the new set of
24/// accepted credentials.
25///
26/// See XLS-80 PermissionedDomains:
27/// `<https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0080-permissioned-domains>`
28#[skip_serializing_none]
29#[derive(
30    Debug,
31    Default,
32    Serialize,
33    Deserialize,
34    PartialEq,
35    Eq,
36    Clone,
37    xrpl_rust_macros::ValidateCurrencies,
38)]
39#[serde(rename_all = "PascalCase")]
40pub struct PermissionedDomainSet<'a> {
41    /// The base fields for all transaction models.
42    ///
43    /// See Transaction Common Fields:
44    /// `<https://xrpl.org/transaction-common-fields.html>`
45    #[serde(flatten)]
46    pub common_fields: CommonFields<'a, NoFlags>,
47    /// The ID of an existing permissioned domain to update. If omitted,
48    /// a new permissioned domain is created.
49    #[serde(rename = "DomainID")]
50    pub domain_id: Option<Cow<'a, str>>,
51    /// The list of credentials accepted by this domain. Each credential
52    /// specifies an issuer and credential type.
53    pub accepted_credentials: Vec<Credential>,
54}
55
56impl<'a> Model for PermissionedDomainSet<'a> {
57    fn get_errors(&self) -> crate::models::XRPLModelResult<()> {
58        validate_accepted_credentials(&self.accepted_credentials)?;
59        if let Some(domain_id) = &self.domain_id {
60            validate_domain_id(domain_id.as_ref())?;
61        }
62        self.validate_currencies()
63    }
64}
65
66/// Validates an `AcceptedCredentials` list per XLS-80: it must contain between 1 and 10
67/// entries, each a valid [`Credential`], with no duplicate `(Issuer, CredentialType)` pairs.
68/// `CredentialType` is compared case-insensitively because rippled decodes the blob bytes and
69/// hashes them, so hex casing is irrelevant at the wire level; `Issuer` is a base58 classic
70/// address (validated by [`validate_credential`]) and is compared verbatim.
71///
72/// Shared by both [`PermissionedDomainSet`] (outbound transaction) and the
73/// `PermissionedDomain` ledger object so the two validate under identical rules.
74pub(crate) fn validate_accepted_credentials(
75    credentials: &[Credential],
76) -> crate::models::XRPLModelResult<()> {
77    if credentials.is_empty() {
78        return Err(XRPLModelException::MissingField(
79            "AcceptedCredentials".into(),
80        ));
81    }
82    if credentials.len() > 10 {
83        return Err(XRPLModelException::ValueTooLong {
84            field: "AcceptedCredentials".into(),
85            max: 10,
86            found: credentials.len(),
87        });
88    }
89    let mut seen: BTreeSet<(alloc::string::String, alloc::string::String)> = BTreeSet::new();
90    for credential in credentials {
91        validate_credential(credential)?;
92        let key = (
93            credential.issuer.clone(),
94            credential.credential_type.to_uppercase(),
95        );
96        if !seen.insert(key) {
97            return Err(XRPLModelException::InvalidValue {
98                field: "AcceptedCredentials".into(),
99                expected: "unique Issuer/CredentialType pairs".into(),
100                found: alloc::format!("{}/{}", credential.issuer, credential.credential_type),
101            });
102        }
103    }
104    Ok(())
105}
106
107/// Validates a DomainID per XLS-80: must be a non-zero 64-character hex string
108/// (the 32-byte hash of the PermissionedDomain ledger entry, serialized as uppercase hex).
109pub(crate) fn validate_domain_id(domain_id: &str) -> crate::models::XRPLModelResult<()> {
110    if domain_id.len() != 64
111        || !domain_id.chars().all(|c| c.is_ascii_hexdigit())
112        || domain_id.chars().all(|c| c == '0')
113    {
114        return Err(XRPLModelException::InvalidValue {
115            field: "DomainID".into(),
116            expected: "non-zero 64-character hex string".into(),
117            found: domain_id.into(),
118        });
119    }
120    Ok(())
121}
122
123/// Validates a `Credential` entry per XLS-80 / rippled `LedgerFormats.cpp`:
124/// `Issuer` must be a valid classic XRPL address and `CredentialType` is an `sfBlob` (hex),
125/// so it must be non-empty, even-length, hex-only, and at most 128 hex chars
126/// (64 bytes, rippled's `MaxCredentialTypeLength`).
127pub(crate) fn validate_credential(credential: &Credential) -> crate::models::XRPLModelResult<()> {
128    if credential.issuer.is_empty() {
129        return Err(XRPLModelException::MissingField("Credential.Issuer".into()));
130    }
131    if !is_valid_classic_address(&credential.issuer) {
132        return Err(XRPLModelException::InvalidValue {
133            field: "Credential.Issuer".into(),
134            expected: "valid classic XRPL address".into(),
135            found: credential.issuer.clone(),
136        });
137    }
138    let ct = &credential.credential_type;
139    if ct.is_empty() {
140        return Err(XRPLModelException::MissingField(
141            "Credential.CredentialType".into(),
142        ));
143    }
144    if ct.len() > 128 {
145        return Err(XRPLModelException::ValueTooLong {
146            field: "Credential.CredentialType".into(),
147            max: 128,
148            found: ct.len(),
149        });
150    }
151    if !ct.len().is_multiple_of(2) || !ct.chars().all(|c| c.is_ascii_hexdigit()) {
152        return Err(XRPLModelException::InvalidValue {
153            field: "Credential.CredentialType".into(),
154            expected: "even-length hex string (<=128 chars)".into(),
155            found: ct.clone(),
156        });
157    }
158    Ok(())
159}
160
161impl<'a> Transaction<'a, NoFlags> for PermissionedDomainSet<'a> {
162    fn get_transaction_type(&self) -> &TransactionType {
163        self.common_fields.get_transaction_type()
164    }
165
166    fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> {
167        self.common_fields.get_common_fields()
168    }
169
170    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
171        self.common_fields.get_mut_common_fields()
172    }
173}
174
175impl<'a> CommonTransactionBuilder<'a, NoFlags> for PermissionedDomainSet<'a> {
176    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
177        &mut self.common_fields
178    }
179
180    fn into_self(self) -> Self {
181        self
182    }
183}
184
185impl<'a> PermissionedDomainSet<'a> {
186    pub fn new(
187        account: Cow<'a, str>,
188        account_txn_id: Option<Cow<'a, str>>,
189        fee: Option<XRPAmount<'a>>,
190        last_ledger_sequence: Option<u32>,
191        memos: Option<Vec<Memo>>,
192        sequence: Option<u32>,
193        signers: Option<Vec<Signer>>,
194        source_tag: Option<u32>,
195        ticket_sequence: Option<u32>,
196        domain_id: Option<Cow<'a, str>>,
197        accepted_credentials: Vec<Credential>,
198    ) -> Self {
199        Self {
200            common_fields: CommonFields {
201                account,
202                transaction_type: TransactionType::PermissionedDomainSet,
203                account_txn_id,
204                fee,
205                flags: FlagCollection::default(),
206                last_ledger_sequence,
207                memos,
208                sequence,
209                signers,
210                source_tag,
211                ticket_sequence,
212                ..Default::default()
213            },
214            domain_id,
215            accepted_credentials,
216        }
217    }
218
219    /// Set the domain ID (for updating an existing domain).
220    pub fn with_domain_id(mut self, domain_id: Cow<'a, str>) -> Self {
221        self.domain_id = Some(domain_id);
222        self
223    }
224
225    /// Set the accepted credentials list.
226    pub fn with_accepted_credentials(mut self, credentials: Vec<Credential>) -> Self {
227        self.accepted_credentials = credentials;
228        self
229    }
230
231    /// Add a single credential to the accepted credentials list.
232    ///
233    /// The 1..=10 bound is enforced by [`Model::get_errors`], not here — the builder
234    /// accumulates freely and validation surfaces an over-limit list as a recoverable
235    /// `XRPLModelException::ValueTooLong` rather than panicking.
236    pub fn with_credential(mut self, credential: Credential) -> Self {
237        self.accepted_credentials.push(credential);
238        self
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use alloc::string::ToString;
246    use alloc::vec;
247
248    /// Shared test account / credential issuer (a valid classic address).
249    const TEST_ACCOUNT: &str = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh";
250
251    #[test]
252    fn test_serde() {
253        let txn = PermissionedDomainSet {
254            common_fields: CommonFields {
255                account: TEST_ACCOUNT.into(),
256                transaction_type: TransactionType::PermissionedDomainSet,
257                fee: Some("10".into()),
258                sequence: Some(1),
259                signing_pub_key: Some("".into()),
260                ..Default::default()
261            },
262            domain_id: None,
263            accepted_credentials: vec![Credential {
264                issuer: TEST_ACCOUNT.to_string(),
265                credential_type: "4B5943".to_string(), // hex("KYC")
266            }],
267        };
268
269        let serialized = serde_json::to_string(&txn).unwrap();
270        let deserialized: PermissionedDomainSet = serde_json::from_str(&serialized).unwrap();
271        assert_eq!(txn, deserialized);
272    }
273
274    #[test]
275    fn test_serde_with_domain_id() {
276        let txn = PermissionedDomainSet {
277            common_fields: CommonFields {
278                account: TEST_ACCOUNT.into(),
279                transaction_type: TransactionType::PermissionedDomainSet,
280                fee: Some("10".into()),
281                sequence: Some(2),
282                signing_pub_key: Some("".into()),
283                ..Default::default()
284            },
285            domain_id: Some(
286                "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2".into(),
287            ),
288            accepted_credentials: vec![Credential {
289                issuer: TEST_ACCOUNT.to_string(),
290                credential_type: "414D4C".to_string(), // hex("AML")
291            }],
292        };
293
294        let serialized = serde_json::to_string(&txn).unwrap();
295
296        // Verify DomainID is present in serialized output
297        assert!(serialized.contains("DomainID"));
298        assert!(
299            serialized.contains("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2")
300        );
301
302        let deserialized: PermissionedDomainSet = serde_json::from_str(&serialized).unwrap();
303        assert_eq!(txn, deserialized);
304    }
305
306    #[test]
307    fn test_builder_pattern() {
308        let txn = PermissionedDomainSet {
309            common_fields: CommonFields {
310                account: TEST_ACCOUNT.into(),
311                transaction_type: TransactionType::PermissionedDomainSet,
312                ..Default::default()
313            },
314            ..Default::default()
315        }
316        .with_fee("12".into())
317        .with_sequence(100)
318        .with_last_ledger_sequence(596447)
319        .with_source_tag(42)
320        .with_credential(Credential {
321            issuer: TEST_ACCOUNT.to_string(),
322            credential_type: "4B5943".to_string(), // hex("KYC")
323        });
324
325        assert_eq!(txn.common_fields.account, TEST_ACCOUNT);
326        assert_eq!(txn.common_fields.fee.as_ref().unwrap().0, "12");
327        assert_eq!(txn.common_fields.sequence, Some(100));
328        assert_eq!(txn.common_fields.last_ledger_sequence, Some(596447));
329        assert_eq!(txn.common_fields.source_tag, Some(42));
330        assert_eq!(txn.accepted_credentials.len(), 1);
331        assert!(txn.domain_id.is_none());
332    }
333
334    #[test]
335    fn test_default() {
336        let txn = PermissionedDomainSet {
337            common_fields: CommonFields {
338                account: TEST_ACCOUNT.into(),
339                transaction_type: TransactionType::PermissionedDomainSet,
340                ..Default::default()
341            },
342            ..Default::default()
343        };
344
345        assert_eq!(txn.common_fields.account, TEST_ACCOUNT);
346        assert_eq!(
347            txn.common_fields.transaction_type,
348            TransactionType::PermissionedDomainSet
349        );
350        assert!(txn.domain_id.is_none());
351        assert!(txn.accepted_credentials.is_empty());
352        assert!(txn.common_fields.fee.is_none());
353        assert!(txn.common_fields.sequence.is_none());
354        // Empty accepted_credentials violates XLS-80 mandated 1..=10 entries.
355        assert!(txn.get_errors().is_err());
356    }
357
358    #[test]
359    fn test_with_credentials() {
360        let txn = PermissionedDomainSet {
361            common_fields: CommonFields {
362                account: TEST_ACCOUNT.into(),
363                transaction_type: TransactionType::PermissionedDomainSet,
364                fee: Some("10".into()),
365                sequence: Some(5),
366                ..Default::default()
367            },
368            domain_id: None,
369            accepted_credentials: vec![
370                Credential {
371                    issuer: TEST_ACCOUNT.to_string(),
372                    credential_type: "4B5943".to_string(), // hex("KYC")
373                },
374                Credential {
375                    issuer: TEST_ACCOUNT.to_string(),
376                    credential_type: "414D4C".to_string(), // hex("AML")
377                },
378                Credential {
379                    issuer: TEST_ACCOUNT.to_string(),
380                    credential_type: "41434352454449544544".to_string(), // hex("ACCREDITED")
381                },
382            ],
383        };
384
385        assert_eq!(txn.accepted_credentials.len(), 3);
386        assert_eq!(txn.accepted_credentials[0].issuer, TEST_ACCOUNT.to_string());
387        assert_eq!(
388            txn.accepted_credentials[1].credential_type,
389            "414D4C".to_string()
390        );
391        assert_eq!(
392            txn.accepted_credentials[2].credential_type,
393            "41434352454449544544".to_string()
394        );
395    }
396
397    #[test]
398    fn test_update_domain() {
399        let domain_id =
400            "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2".to_string();
401        let txn = PermissionedDomainSet {
402            common_fields: CommonFields {
403                account: TEST_ACCOUNT.into(),
404                transaction_type: TransactionType::PermissionedDomainSet,
405                fee: Some("10".into()),
406                sequence: Some(10),
407                ..Default::default()
408            },
409            domain_id: Some(domain_id.clone().into()),
410            accepted_credentials: vec![Credential {
411                issuer: "rNewIssuer".to_string(),
412                credential_type: "5645524946494544".to_string(), // hex("VERIFIED")
413            }],
414        };
415
416        assert_eq!(txn.domain_id, Some(domain_id.into()));
417        assert_eq!(txn.accepted_credentials.len(), 1);
418    }
419
420    #[test]
421    fn test_create_domain() {
422        let txn = PermissionedDomainSet {
423            common_fields: CommonFields {
424                account: TEST_ACCOUNT.into(),
425                transaction_type: TransactionType::PermissionedDomainSet,
426                fee: Some("10".into()),
427                sequence: Some(1),
428                ..Default::default()
429            },
430            domain_id: None,
431            accepted_credentials: vec![Credential {
432                issuer: TEST_ACCOUNT.to_string(),
433                credential_type: "4B5943".to_string(), // hex("KYC")
434            }],
435        };
436
437        // Creating a new domain means domain_id is None
438        assert!(txn.domain_id.is_none());
439        assert_eq!(txn.accepted_credentials.len(), 1);
440    }
441
442    #[test]
443    fn test_new_constructor() {
444        let txn = PermissionedDomainSet::new(
445            TEST_ACCOUNT.into(),
446            None,
447            Some("12".into()),
448            Some(596447),
449            None,
450            Some(1),
451            None,
452            None,
453            None,
454            None,
455            vec![Credential {
456                issuer: TEST_ACCOUNT.to_string(),
457                credential_type: "4B5943".to_string(), // hex("KYC")
458            }],
459        );
460
461        assert_eq!(txn.common_fields.account, TEST_ACCOUNT);
462        assert_eq!(
463            txn.common_fields.transaction_type,
464            TransactionType::PermissionedDomainSet
465        );
466        assert_eq!(txn.common_fields.fee.as_ref().unwrap().0, "12");
467        assert_eq!(txn.common_fields.sequence, Some(1));
468        assert_eq!(txn.common_fields.last_ledger_sequence, Some(596447));
469        assert!(txn.domain_id.is_none());
470        assert_eq!(txn.accepted_credentials.len(), 1);
471    }
472
473    #[test]
474    fn test_with_domain_id_builder() {
475        let txn = PermissionedDomainSet {
476            common_fields: CommonFields {
477                account: TEST_ACCOUNT.into(),
478                transaction_type: TransactionType::PermissionedDomainSet,
479                ..Default::default()
480            },
481            ..Default::default()
482        }
483        .with_domain_id("AABB0011".into())
484        .with_accepted_credentials(vec![Credential {
485            issuer: TEST_ACCOUNT.to_string(),
486            credential_type: "4B5943".to_string(), // hex("KYC")
487        }]);
488
489        assert_eq!(txn.domain_id, Some("AABB0011".into()));
490        assert_eq!(txn.accepted_credentials.len(), 1);
491    }
492
493    #[test]
494    fn test_with_memo() {
495        let txn = PermissionedDomainSet {
496            common_fields: CommonFields {
497                account: TEST_ACCOUNT.into(),
498                transaction_type: TransactionType::PermissionedDomainSet,
499                ..Default::default()
500            },
501            ..Default::default()
502        }
503        .with_fee("10".into())
504        .with_sequence(1)
505        .with_memo(Memo {
506            memo_data: Some("creating domain".into()),
507            memo_format: None,
508            memo_type: Some("text".into()),
509        })
510        .with_credential(Credential {
511            issuer: TEST_ACCOUNT.to_string(),
512            credential_type: "4B5943".to_string(), // hex("KYC")
513        });
514
515        assert_eq!(txn.common_fields.memos.as_ref().unwrap().len(), 1);
516        assert_eq!(txn.accepted_credentials.len(), 1);
517    }
518
519    #[test]
520    fn test_empty_credentials_rejected() {
521        // XLS-80 mandates AcceptedCredentials has 1..=10 entries; empty must fail validation.
522        let txn = PermissionedDomainSet {
523            common_fields: CommonFields {
524                account: TEST_ACCOUNT.into(),
525                transaction_type: TransactionType::PermissionedDomainSet,
526                fee: Some("10".into()),
527                sequence: Some(1),
528                ..Default::default()
529            },
530            domain_id: Some("AABB0011".into()),
531            accepted_credentials: vec![],
532        };
533
534        let result = txn.get_errors();
535        assert!(result.is_err());
536        assert_eq!(
537            result.unwrap_err(),
538            XRPLModelException::MissingField("AcceptedCredentials".into())
539        );
540    }
541
542    #[test]
543    fn test_too_many_credentials_rejected() {
544        // XLS-80 caps AcceptedCredentials at 10 entries.
545        let credentials: Vec<Credential> = (0..11)
546            .map(|_| Credential {
547                issuer: TEST_ACCOUNT.to_string(),
548                credential_type: "4B5943".to_string(),
549            })
550            .collect();
551        let txn = PermissionedDomainSet {
552            common_fields: CommonFields {
553                account: TEST_ACCOUNT.into(),
554                transaction_type: TransactionType::PermissionedDomainSet,
555                ..Default::default()
556            },
557            domain_id: None,
558            accepted_credentials: credentials,
559        };
560
561        let result = txn.get_errors();
562        assert!(result.is_err());
563        assert!(matches!(
564            result.unwrap_err(),
565            XRPLModelException::ValueTooLong {
566                max: 10,
567                found: 11,
568                ..
569            }
570        ));
571    }
572
573    #[test]
574    fn test_non_hex_credential_type_rejected() {
575        // CredentialType is an sfBlob; non-hex values must fail validation.
576        let txn = PermissionedDomainSet {
577            common_fields: CommonFields {
578                account: TEST_ACCOUNT.into(),
579                transaction_type: TransactionType::PermissionedDomainSet,
580                ..Default::default()
581            },
582            domain_id: None,
583            accepted_credentials: vec![Credential {
584                issuer: TEST_ACCOUNT.to_string(),
585                credential_type: "KYC".to_string(), // not hex
586            }],
587        };
588        let result = txn.get_errors();
589        assert!(result.is_err());
590        assert!(matches!(
591            result.unwrap_err(),
592            XRPLModelException::InvalidValue { .. }
593        ));
594    }
595
596    #[test]
597    fn test_credential_type_64_bytes_accepted() {
598        // 64 bytes hex-encoded = 128 chars; this is the rippled maximum.
599        let credential = Credential {
600            issuer: TEST_ACCOUNT.to_string(),
601            credential_type: "A".repeat(128),
602        };
603
604        assert!(validate_credential(&credential).is_ok());
605    }
606
607    #[test]
608    fn test_credential_type_over_64_bytes_rejected() {
609        let too_long = "A".repeat(130);
610        let txn = PermissionedDomainSet {
611            common_fields: CommonFields {
612                account: TEST_ACCOUNT.into(),
613                transaction_type: TransactionType::PermissionedDomainSet,
614                ..Default::default()
615            },
616            domain_id: None,
617            accepted_credentials: vec![Credential {
618                issuer: TEST_ACCOUNT.to_string(),
619                credential_type: too_long,
620            }],
621        };
622        let result = txn.get_errors();
623        assert!(result.is_err());
624        assert!(matches!(
625            result.unwrap_err(),
626            XRPLModelException::ValueTooLong { max: 128, .. }
627        ));
628    }
629
630    #[test]
631    fn test_duplicate_credentials_rejected() {
632        let duplicate = Credential {
633            issuer: TEST_ACCOUNT.to_string(),
634            credential_type: "4B5943".to_string(),
635        };
636        let txn = PermissionedDomainSet {
637            common_fields: CommonFields {
638                account: TEST_ACCOUNT.into(),
639                transaction_type: TransactionType::PermissionedDomainSet,
640                ..Default::default()
641            },
642            domain_id: None,
643            accepted_credentials: vec![duplicate.clone(), duplicate],
644        };
645
646        assert!(matches!(
647            txn.get_errors(),
648            Err(XRPLModelException::InvalidValue { .. })
649        ));
650    }
651
652    #[test]
653    fn test_set_all_zero_domain_id_rejected() {
654        let txn = PermissionedDomainSet {
655            common_fields: CommonFields {
656                account: TEST_ACCOUNT.into(),
657                transaction_type: TransactionType::PermissionedDomainSet,
658                ..Default::default()
659            },
660            domain_id: Some("0".repeat(64).into()),
661            accepted_credentials: vec![Credential {
662                issuer: TEST_ACCOUNT.to_string(),
663                credential_type: "4B5943".to_string(),
664            }],
665        };
666
667        assert!(matches!(
668            txn.get_errors(),
669            Err(XRPLModelException::InvalidValue { .. })
670        ));
671    }
672
673    #[test]
674    fn test_duplicate_credentials_case_insensitive_rejected() {
675        // "4b5943" and "4B5943" are the same credential on the wire — dedup must
676        // collapse them via the to_uppercase() normalization in validate_accepted_credentials.
677        let txn = PermissionedDomainSet {
678            common_fields: CommonFields {
679                account: TEST_ACCOUNT.into(),
680                transaction_type: TransactionType::PermissionedDomainSet,
681                ..Default::default()
682            },
683            domain_id: None,
684            accepted_credentials: vec![
685                Credential {
686                    issuer: TEST_ACCOUNT.to_string(),
687                    credential_type: "4b5943".to_string(),
688                },
689                Credential {
690                    issuer: TEST_ACCOUNT.to_string(),
691                    credential_type: "4B5943".to_string(),
692                },
693            ],
694        };
695
696        assert!(matches!(
697            txn.get_errors(),
698            Err(XRPLModelException::InvalidValue { .. })
699        ));
700    }
701
702    #[test]
703    fn test_set_wrong_length_domain_id_rejected() {
704        let txn = PermissionedDomainSet {
705            common_fields: CommonFields {
706                account: TEST_ACCOUNT.into(),
707                transaction_type: TransactionType::PermissionedDomainSet,
708                ..Default::default()
709            },
710            domain_id: Some("ABCD".into()), // 4 chars, not 64
711            accepted_credentials: vec![Credential {
712                issuer: TEST_ACCOUNT.to_string(),
713                credential_type: "4B5943".to_string(),
714            }],
715        };
716
717        assert!(matches!(
718            txn.get_errors(),
719            Err(XRPLModelException::InvalidValue { .. })
720        ));
721    }
722
723    #[test]
724    fn test_set_non_hex_domain_id_rejected() {
725        let txn = PermissionedDomainSet {
726            common_fields: CommonFields {
727                account: TEST_ACCOUNT.into(),
728                transaction_type: TransactionType::PermissionedDomainSet,
729                ..Default::default()
730            },
731            domain_id: Some("G".repeat(64).into()), // 64 chars but 'G' is not hex
732            accepted_credentials: vec![Credential {
733                issuer: TEST_ACCOUNT.to_string(),
734                credential_type: "4B5943".to_string(),
735            }],
736        };
737
738        assert!(matches!(
739            txn.get_errors(),
740            Err(XRPLModelException::InvalidValue { .. })
741        ));
742    }
743
744    #[test]
745    fn test_ticket_sequence() {
746        let txn = PermissionedDomainSet {
747            common_fields: CommonFields {
748                account: TEST_ACCOUNT.into(),
749                transaction_type: TransactionType::PermissionedDomainSet,
750                ..Default::default()
751            },
752            ..Default::default()
753        }
754        .with_ticket_sequence(42)
755        .with_fee("10".into())
756        .with_credential(Credential {
757            issuer: TEST_ACCOUNT.to_string(),
758            credential_type: "4B5943".to_string(), // hex("KYC")
759        });
760
761        assert_eq!(txn.common_fields.ticket_sequence, Some(42));
762        assert!(txn.common_fields.sequence.is_none());
763    }
764
765    #[test]
766    fn test_credential_empty_issuer_rejected() {
767        let txn = PermissionedDomainSet {
768            common_fields: CommonFields {
769                account: TEST_ACCOUNT.into(),
770                transaction_type: TransactionType::PermissionedDomainSet,
771                ..Default::default()
772            },
773            domain_id: None,
774            accepted_credentials: vec![Credential {
775                issuer: "".to_string(),
776                credential_type: "4B5943".to_string(), // hex("KYC")
777            }],
778        };
779
780        let result = txn.get_errors();
781        assert!(result.is_err());
782        assert_eq!(
783            result.unwrap_err(),
784            XRPLModelException::MissingField("Credential.Issuer".into())
785        );
786    }
787
788    #[test]
789    fn test_credential_empty_credential_type_rejected() {
790        let txn = PermissionedDomainSet {
791            common_fields: CommonFields {
792                account: TEST_ACCOUNT.into(),
793                transaction_type: TransactionType::PermissionedDomainSet,
794                ..Default::default()
795            },
796            domain_id: None,
797            accepted_credentials: vec![Credential {
798                issuer: TEST_ACCOUNT.to_string(),
799                credential_type: "".to_string(),
800            }],
801        };
802
803        let result = txn.get_errors();
804        assert!(result.is_err());
805        assert_eq!(
806            result.unwrap_err(),
807            XRPLModelException::MissingField("Credential.CredentialType".into())
808        );
809    }
810}