Skip to main content

xrpl/models/ledger/objects/
credential.rs

1use crate::models::ledger::objects::LedgerEntryType;
2use crate::models::FlagCollection;
3use crate::models::Model;
4use alloc::borrow::Cow;
5
6use serde::{Deserialize, Serialize};
7use serde_repr::{Deserialize_repr, Serialize_repr};
8
9use serde_with::skip_serializing_none;
10use strum_macros::{AsRefStr, Display, EnumIter};
11
12use super::{CommonFields, LedgerObject};
13
14#[derive(
15    Debug, Eq, PartialEq, Clone, Serialize_repr, Deserialize_repr, Display, AsRefStr, EnumIter,
16)]
17#[repr(u32)]
18pub enum CredentialFlag {
19    /// Credential has been accepted by the subject.
20    LsfAccepted = 0x00010000,
21}
22
23/// A `Credential` object is an on-ledger representation of a credential.
24///
25/// `<https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0070-credentials>`
26#[skip_serializing_none]
27#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
28#[serde(rename_all = "PascalCase")]
29pub struct Credential<'a> {
30    /// The base fields for all ledger object models.
31    #[serde(flatten)]
32    pub common_fields: CommonFields<'a, CredentialFlag>,
33    /// The account the credential is for.
34    pub subject: Cow<'a, str>,
35    /// The account that issued the credential.
36    pub issuer: Cow<'a, str>,
37    /// A hex-encoded value identifying the credential type from this issuer.
38    pub credential_type: Cow<'a, str>,
39    /// Optional expiration for the credential.
40    pub expiration: Option<u32>,
41    /// Optional additional data, represented as a hex-encoded string.
42    #[serde(rename = "URI")]
43    pub uri: Option<Cow<'a, str>>,
44    /// A hint indicating which page of the subject's owner directory links to this object.
45    /// Omitted for self-issued credentials, which only appear in the issuer's owner directory.
46    pub subject_node: Option<Cow<'a, str>>,
47    /// A hint indicating which page of the issuer's owner directory links to this object.
48    pub issuer_node: Cow<'a, str>,
49    /// The identifying hash of the transaction that most recently modified this object.
50    #[serde(rename = "PreviousTxnID")]
51    pub previous_txn_id: Cow<'a, str>,
52    /// The index of the ledger containing the transaction that most recently modified this object.
53    pub previous_txn_lgr_seq: u32,
54}
55
56impl<'a> Model for Credential<'a> {}
57
58impl<'a> LedgerObject<CredentialFlag> for Credential<'a> {
59    fn get_ledger_entry_type(&self) -> LedgerEntryType {
60        self.common_fields.get_ledger_entry_type()
61    }
62}
63
64impl<'a> Credential<'a> {
65    #[allow(clippy::too_many_arguments)]
66    pub fn new(
67        index: Option<Cow<'a, str>>,
68        ledger_index: Option<Cow<'a, str>>,
69        subject: Cow<'a, str>,
70        issuer: Cow<'a, str>,
71        credential_type: Cow<'a, str>,
72        expiration: Option<u32>,
73        uri: Option<Cow<'a, str>>,
74        subject_node: Option<Cow<'a, str>>,
75        issuer_node: Cow<'a, str>,
76        previous_txn_id: Cow<'a, str>,
77        previous_txn_lgr_seq: u32,
78    ) -> Self {
79        Self {
80            common_fields: CommonFields {
81                flags: FlagCollection::default(),
82                ledger_entry_type: LedgerEntryType::Credential,
83                index,
84                ledger_index,
85            },
86            subject,
87            issuer,
88            credential_type,
89            expiration,
90            uri,
91            subject_node,
92            issuer_node,
93            previous_txn_id,
94            previous_txn_lgr_seq,
95        }
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn test_serde() {
105        let credential = Credential::new(
106            Some(Cow::from(
107                "DD40031C6C21164E7673A47C35513D52A6B0F1349A873EE0D188D8994CD4D001",
108            )),
109            None,
110            Cow::from("rALICE1111111111111111111111111111"),
111            Cow::from("rISABEL111111111111111111111111111"),
112            Cow::from("4B5943"),
113            Some(789004799),
114            Some(Cow::from(
115                "69736162656C2E636F6D2F63726564656E7469616C732F6B79632F616C696365",
116            )),
117            Some(Cow::from("0000000000000000")),
118            Cow::from("0000000000000000"),
119            Cow::from("3E8964D5A86B3CD6B9ECB33310D4E073D64C865A5B866200AD2B7E29F8326702"),
120            8,
121        );
122        let serialized = serde_json::to_string(&credential).unwrap();
123
124        let deserialized: Credential = serde_json::from_str(&serialized).unwrap();
125
126        assert_eq!(credential, deserialized);
127    }
128
129    #[test]
130    fn test_serde_round_trip_all_fields() {
131        // Full credential with every field populated
132        let credential = Credential {
133            common_fields: CommonFields {
134                flags: FlagCollection::default(),
135                ledger_entry_type: LedgerEntryType::Credential,
136                index: Some(Cow::from(
137                    "DD40031C6C21164E7673A47C35513D52A6B0F1349A873EE0D188D8994CD4D001",
138                )),
139                ledger_index: Some(Cow::from("42")),
140            },
141            subject: Cow::from("rALICE1111111111111111111111111111"),
142            issuer: Cow::from("rISABEL111111111111111111111111111"),
143            credential_type: Cow::from("4B5943"),
144            expiration: Some(789004799),
145            uri: Some(Cow::from(
146                "69736162656C2E636F6D2F63726564656E7469616C732F6B79632F616C696365",
147            )),
148            subject_node: Some(Cow::from("0000000000000000")),
149            issuer_node: Cow::from("0000000000000001"),
150            previous_txn_id: Cow::from(
151                "3E8964D5A86B3CD6B9ECB33310D4E073D64C865A5B866200AD2B7E29F8326702",
152            ),
153            previous_txn_lgr_seq: 8,
154        };
155        let json = serde_json::to_string(&credential).unwrap();
156        let deserialized: Credential = serde_json::from_str(&json).unwrap();
157        assert_eq!(credential, deserialized);
158    }
159
160    #[test]
161    fn test_serde_round_trip_optional_fields_omitted() {
162        // Credential without optional expiration and URI
163        let credential = Credential {
164            common_fields: CommonFields {
165                flags: FlagCollection::default(),
166                ledger_entry_type: LedgerEntryType::Credential,
167                index: Some(Cow::from(
168                    "DD40031C6C21164E7673A47C35513D52A6B0F1349A873EE0D188D8994CD4D001",
169                )),
170                ledger_index: None,
171            },
172            subject: Cow::from("rALICE1111111111111111111111111111"),
173            issuer: Cow::from("rISABEL111111111111111111111111111"),
174            credential_type: Cow::from("4B5943"),
175            expiration: None,
176            uri: None,
177            subject_node: Some(Cow::from("0000000000000000")),
178            issuer_node: Cow::from("0000000000000000"),
179            previous_txn_id: Cow::from(
180                "3E8964D5A86B3CD6B9ECB33310D4E073D64C865A5B866200AD2B7E29F8326702",
181            ),
182            previous_txn_lgr_seq: 5,
183        };
184        let json = serde_json::to_string(&credential).unwrap();
185        // Verify optional fields are absent from serialized JSON
186        assert!(!json.contains("Expiration"));
187        assert!(!json.contains("URI"));
188
189        let deserialized: Credential = serde_json::from_str(&json).unwrap();
190        assert_eq!(credential, deserialized);
191        assert!(deserialized.expiration.is_none());
192        assert!(deserialized.uri.is_none());
193    }
194
195    #[test]
196    fn test_self_issued_credential_allows_missing_subject_node() {
197        let json = r#"{
198            "LedgerEntryType":"Credential",
199            "Flags":65536,
200            "Subject":"rSELF11111111111111111111111111111",
201            "Issuer":"rSELF11111111111111111111111111111",
202            "CredentialType":"4B5943",
203            "IssuerNode":"0000000000000000",
204            "PreviousTxnID":"3E8964D5A86B3CD6B9ECB33310D4E073D64C865A5B866200AD2B7E29F8326702",
205            "PreviousTxnLgrSeq":10
206        }"#;
207
208        let credential: Credential = serde_json::from_str(json).unwrap();
209        assert!(credential.subject_node.is_none());
210        assert_eq!(credential.subject, credential.issuer);
211    }
212
213    #[test]
214    fn test_lsf_accepted_flag_value() {
215        // Verify the lsfAccepted flag has the correct value per the spec: 0x00010000
216        assert_eq!(CredentialFlag::LsfAccepted as u32, 0x00010000);
217    }
218
219    #[test]
220    fn test_serde_with_accepted_flag() {
221        // Credential with the lsfAccepted flag set
222        let mut flags = FlagCollection::default();
223        flags.0.push(CredentialFlag::LsfAccepted);
224        let credential = Credential {
225            common_fields: CommonFields {
226                flags,
227                ledger_entry_type: LedgerEntryType::Credential,
228                index: Some(Cow::from(
229                    "DD40031C6C21164E7673A47C35513D52A6B0F1349A873EE0D188D8994CD4D001",
230                )),
231                ledger_index: None,
232            },
233            subject: Cow::from("rALICE1111111111111111111111111111"),
234            issuer: Cow::from("rISABEL111111111111111111111111111"),
235            credential_type: Cow::from("4B5943"),
236            expiration: None,
237            uri: None,
238            subject_node: Some(Cow::from("0000000000000000")),
239            issuer_node: Cow::from("0000000000000000"),
240            previous_txn_id: Cow::from(
241                "3E8964D5A86B3CD6B9ECB33310D4E073D64C865A5B866200AD2B7E29F8326702",
242            ),
243            previous_txn_lgr_seq: 10,
244        };
245        let json = serde_json::to_string(&credential).unwrap();
246        let deserialized: Credential = serde_json::from_str(&json).unwrap();
247        assert_eq!(credential, deserialized);
248    }
249}