Skip to main content

xrpl/models/ledger/objects/
permissioned_domain.rs

1use crate::models::ledger::objects::LedgerEntryType;
2use crate::models::transactions::Credential;
3use crate::models::FlagCollection;
4use crate::models::NoFlags;
5use alloc::borrow::Cow;
6use alloc::vec::Vec;
7use serde::{Deserialize, Serialize};
8use serde_with::skip_serializing_none;
9
10use super::{CommonFields, LedgerObject};
11
12/// The `PermissionedDomain` ledger entry represents a permissioned domain
13/// on the XRP Ledger. A permissioned domain defines a set of accepted
14/// credentials that restrict access to certain functionality.
15///
16/// See XLS-80 PermissionedDomains:
17/// `<https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0080-permissioned-domains>`
18#[skip_serializing_none]
19#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
20#[serde(rename_all = "PascalCase")]
21pub struct PermissionedDomain<'a> {
22    /// The base fields for all ledger object models.
23    ///
24    /// See Ledger Object Common Fields:
25    /// `<https://xrpl.org/ledger-entry-common-fields.html>`
26    #[serde(flatten)]
27    pub common_fields: CommonFields<'a, NoFlags>,
28    /// The account that owns this permissioned domain.
29    pub owner: Cow<'a, str>,
30    /// The list of credentials accepted by this domain.
31    pub accepted_credentials: Vec<Credential>,
32    /// The sequence number of the PermissionedDomainSet transaction that
33    /// created this domain.
34    pub sequence: u32,
35    /// A hint indicating which page of the owner directory links to this object,
36    /// in case the directory consists of multiple pages.
37    pub owner_node: Cow<'a, str>,
38    /// The identifying hash of the transaction that most recently modified
39    /// this object.
40    #[serde(rename = "PreviousTxnID")]
41    pub previous_txn_id: Cow<'a, str>,
42    /// The index of the ledger that contains the transaction that most
43    /// recently modified this object.
44    pub previous_txn_lgr_seq: u32,
45}
46
47impl<'a> LedgerObject<NoFlags> for PermissionedDomain<'a> {
48    fn get_ledger_entry_type(&self) -> LedgerEntryType {
49        self.common_fields.get_ledger_entry_type()
50    }
51}
52
53impl<'a> crate::models::Model for PermissionedDomain<'a> {
54    fn get_errors(&self) -> crate::models::XRPLModelResult<()> {
55        use crate::core::addresscodec::is_valid_classic_address;
56        use crate::models::exceptions::XRPLModelException;
57        use crate::models::transactions::permissioned_domain_set::validate_accepted_credentials;
58
59        if !is_valid_classic_address(&self.owner) {
60            return Err(XRPLModelException::InvalidValue {
61                field: "owner".into(),
62                expected: "valid classic XRPL address".into(),
63                found: self.owner.clone().into_owned(),
64            });
65        }
66        // Same credential-list rules as PermissionedDomainSet (1..=10, valid, no dupes)
67        // so the ledger object and its originating transaction validate identically.
68        validate_accepted_credentials(&self.accepted_credentials)
69    }
70}
71
72impl<'a> PermissionedDomain<'a> {
73    pub fn new(
74        index: Option<Cow<'a, str>>,
75        ledger_index: Option<Cow<'a, str>>,
76        owner: Cow<'a, str>,
77        accepted_credentials: Vec<Credential>,
78        sequence: u32,
79        owner_node: Cow<'a, str>,
80        previous_txn_id: Cow<'a, str>,
81        previous_txn_lgr_seq: u32,
82    ) -> Self {
83        Self {
84            common_fields: CommonFields {
85                flags: FlagCollection::default(),
86                ledger_entry_type: LedgerEntryType::PermissionedDomain,
87                index,
88                ledger_index,
89            },
90            owner,
91            accepted_credentials,
92            sequence,
93            owner_node,
94            previous_txn_id,
95            previous_txn_lgr_seq,
96        }
97    }
98}
99
100#[cfg(test)]
101mod test_serde {
102    use super::*;
103    use alloc::borrow::Cow;
104    use alloc::string::ToString;
105    use alloc::vec;
106
107    /// Shared test owner / credential issuer (a valid classic address).
108    const TEST_ACCOUNT: &str = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh";
109
110    #[test]
111    fn test_serialize() {
112        let domain = PermissionedDomain {
113            common_fields: CommonFields {
114                flags: FlagCollection::default(),
115                ledger_entry_type: LedgerEntryType::PermissionedDomain,
116                index: Some(Cow::from("ForTest")),
117                ledger_index: None,
118            },
119            owner: Cow::from(TEST_ACCOUNT),
120            accepted_credentials: vec![
121                Credential {
122                    issuer: "rIssuerA".to_string(),
123                    credential_type: "4B5943".to_string(), // hex("KYC")
124                },
125                Credential {
126                    issuer: "rIssuerB".to_string(),
127                    credential_type: "414D4C".to_string(), // hex("AML")
128                },
129            ],
130            sequence: 1,
131            owner_node: Cow::from("0"),
132            previous_txn_id: Cow::from(
133                "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2",
134            ),
135            previous_txn_lgr_seq: 1000,
136        };
137
138        let serialized = serde_json::to_string(&domain).unwrap();
139
140        // Assert PascalCase JSON keys so silent field renames are caught.
141        assert!(serialized.contains("\"AcceptedCredentials\""));
142        assert!(serialized.contains("\"PreviousTxnID\""));
143        assert!(serialized.contains("\"PreviousTxnLgrSeq\""));
144        assert!(serialized.contains("\"Owner\""));
145        assert!(serialized.contains("\"OwnerNode\""));
146        assert!(serialized.contains("\"Sequence\""));
147        assert!(serialized.contains("\"LedgerEntryType\""));
148
149        let deserialized: PermissionedDomain = serde_json::from_str(&serialized).unwrap();
150        assert_eq!(domain, deserialized);
151    }
152
153    #[test]
154    fn test_ledger_entry_type() {
155        let domain = PermissionedDomain {
156            common_fields: CommonFields {
157                flags: FlagCollection::default(),
158                ledger_entry_type: LedgerEntryType::PermissionedDomain,
159                index: None,
160                ledger_index: None,
161            },
162            owner: Cow::from("rOwner"),
163            accepted_credentials: vec![Credential {
164                issuer: "rIssuer".to_string(),
165                credential_type: "4B5943".to_string(),
166            }],
167            sequence: 1,
168            owner_node: Cow::from("0"),
169            previous_txn_id: Cow::from(
170                "0000000000000000000000000000000000000000000000000000000000000000",
171            ),
172            previous_txn_lgr_seq: 1,
173        };
174
175        assert_eq!(
176            domain.get_ledger_entry_type(),
177            LedgerEntryType::PermissionedDomain
178        );
179    }
180
181    #[test]
182    fn test_fields() {
183        let domain = PermissionedDomain {
184            common_fields: CommonFields {
185                flags: FlagCollection::default(),
186                ledger_entry_type: LedgerEntryType::PermissionedDomain,
187                index: Some(Cow::from("TestIndex")),
188                ledger_index: Some(Cow::from("TestLedgerIndex")),
189            },
190            owner: Cow::from("rOwnerXYZ"),
191            accepted_credentials: vec![Credential {
192                issuer: "rIssuerXYZ".to_string(),
193                credential_type: "41434352454449544544".to_string(), // hex("ACCREDITED")
194            }],
195            sequence: 42,
196            owner_node: Cow::from("7"),
197            previous_txn_id: Cow::from(
198                "1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF",
199            ),
200            previous_txn_lgr_seq: 999,
201        };
202
203        assert_eq!(domain.owner, "rOwnerXYZ");
204        assert_eq!(domain.sequence, 42);
205        assert_eq!(domain.owner_node, Cow::from("7"));
206        assert_eq!(
207            domain.previous_txn_id,
208            "1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF"
209        );
210        assert_eq!(domain.previous_txn_lgr_seq, 999);
211        assert_eq!(domain.accepted_credentials.len(), 1);
212        assert_eq!(domain.common_fields.index, Some(Cow::from("TestIndex")));
213        assert_eq!(
214            domain.common_fields.ledger_index,
215            Some(Cow::from("TestLedgerIndex"))
216        );
217    }
218
219    use crate::models::exceptions::XRPLModelException;
220    use crate::models::Model;
221
222    /// A valid PermissionedDomain ledger object (real classic addresses) for validation tests.
223    fn valid_domain(credentials: Vec<Credential>) -> PermissionedDomain<'static> {
224        PermissionedDomain {
225            common_fields: CommonFields {
226                flags: FlagCollection::default(),
227                ledger_entry_type: LedgerEntryType::PermissionedDomain,
228                index: None,
229                ledger_index: None,
230            },
231            owner: Cow::from(TEST_ACCOUNT),
232            accepted_credentials: credentials,
233            sequence: 1,
234            owner_node: Cow::from("0"),
235            previous_txn_id: Cow::from(
236                "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4E5F6A1B2",
237            ),
238            previous_txn_lgr_seq: 1000,
239        }
240    }
241
242    fn kyc() -> Credential {
243        Credential {
244            issuer: TEST_ACCOUNT.to_string(),
245            credential_type: "4B5943".to_string(),
246        }
247    }
248
249    #[test]
250    fn test_get_errors_valid() {
251        assert!(valid_domain(vec![kyc()]).get_errors().is_ok());
252    }
253
254    #[test]
255    fn test_get_errors_invalid_owner_rejected() {
256        let mut domain = valid_domain(vec![kyc()]);
257        domain.owner = Cow::from("not-an-address");
258        assert!(matches!(
259            domain.get_errors(),
260            Err(XRPLModelException::InvalidValue { .. })
261        ));
262    }
263
264    #[test]
265    fn test_get_errors_empty_credentials_rejected() {
266        assert!(matches!(
267            valid_domain(vec![]).get_errors(),
268            Err(XRPLModelException::MissingField(_))
269        ));
270    }
271
272    #[test]
273    fn test_get_errors_duplicate_credentials_rejected() {
274        // Shares the transaction's dedup rule (case-insensitive CredentialType).
275        let dup_lower = Credential {
276            issuer: TEST_ACCOUNT.to_string(),
277            credential_type: "4b5943".to_string(),
278        };
279        assert!(matches!(
280            valid_domain(vec![kyc(), dup_lower]).get_errors(),
281            Err(XRPLModelException::InvalidValue { .. })
282        ));
283    }
284
285    #[test]
286    fn test_get_errors_too_many_credentials_rejected() {
287        let creds: Vec<Credential> = (0..11)
288            .map(|i| Credential {
289                issuer: TEST_ACCOUNT.to_string(),
290                credential_type: alloc::format!("{:06X}", i),
291            })
292            .collect();
293        assert!(matches!(
294            valid_domain(creds).get_errors(),
295            Err(XRPLModelException::ValueTooLong { .. })
296        ));
297    }
298}