Skip to main content

xrpl/models/transactions/
credential_create.rs

1use alloc::borrow::Cow;
2use alloc::vec::Vec;
3use serde::{Deserialize, Serialize};
4use serde_with::skip_serializing_none;
5
6use crate::constants::MAX_CREDENTIAL_URI_LENGTH;
7use crate::models::amount::XRPAmount;
8use crate::models::transactions::CommonFields;
9use crate::models::{
10    transactions::{Memo, Signer, Transaction, TransactionType},
11    Model, XRPLModelException, XRPLModelResult,
12};
13use crate::models::{FlagCollection, NoFlags};
14
15use super::CommonTransactionBuilder;
16
17/// A CredentialCreate transaction creates a credential object.
18///
19/// See CredentialCreate:
20/// `<https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0070-credentials>`
21#[skip_serializing_none]
22#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq, Clone)]
23#[serde(rename_all = "PascalCase")]
24pub struct CredentialCreate<'a> {
25    /// The base fields for all transaction models.
26    #[serde(flatten)]
27    pub common_fields: CommonFields<'a, NoFlags>,
28    /// The account this credential is issued to.
29    pub subject: Cow<'a, str>,
30    /// A hex-encoded value identifying the credential type from this issuer.
31    pub credential_type: Cow<'a, str>,
32    /// Optional expiration for the credential.
33    pub expiration: Option<u32>,
34    /// Optional additional data, represented as a hex-encoded string.
35    #[serde(rename = "URI")]
36    pub uri: Option<Cow<'a, str>>,
37}
38
39impl<'a> Model for CredentialCreate<'a> {
40    fn get_errors(&self) -> XRPLModelResult<()> {
41        self._get_credential_type_error()?;
42        self._get_uri_error()
43    }
44}
45
46impl<'a> Transaction<'a, NoFlags> for CredentialCreate<'a> {
47    fn get_transaction_type(&self) -> &TransactionType {
48        self.common_fields.get_transaction_type()
49    }
50
51    fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> {
52        self.common_fields.get_common_fields()
53    }
54
55    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
56        self.common_fields.get_mut_common_fields()
57    }
58}
59
60impl<'a> CommonTransactionBuilder<'a, NoFlags> for CredentialCreate<'a> {
61    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
62        &mut self.common_fields
63    }
64
65    fn into_self(self) -> Self {
66        self
67    }
68}
69
70impl<'a> CredentialCreate<'a> {
71    #[allow(clippy::too_many_arguments)]
72    pub fn new(
73        account: Cow<'a, str>,
74        account_txn_id: Option<Cow<'a, str>>,
75        fee: Option<XRPAmount<'a>>,
76        last_ledger_sequence: Option<u32>,
77        memos: Option<Vec<Memo>>,
78        sequence: Option<u32>,
79        signers: Option<Vec<Signer>>,
80        source_tag: Option<u32>,
81        ticket_sequence: Option<u32>,
82        subject: Cow<'a, str>,
83        credential_type: Cow<'a, str>,
84        expiration: Option<u32>,
85        uri: Option<Cow<'a, str>>,
86    ) -> Self {
87        Self {
88            common_fields: CommonFields::new(
89                account,
90                TransactionType::CredentialCreate,
91                account_txn_id,
92                fee,
93                Some(FlagCollection::default()),
94                last_ledger_sequence,
95                memos,
96                None,
97                sequence,
98                signers,
99                None,
100                source_tag,
101                ticket_sequence,
102                None,
103            ),
104            subject,
105            credential_type,
106            expiration,
107            uri,
108        }
109    }
110
111    pub fn with_expiration(mut self, expiration: u32) -> Self {
112        self.expiration = Some(expiration);
113        self
114    }
115
116    pub fn with_uri(mut self, uri: Cow<'a, str>) -> Self {
117        self.uri = Some(uri);
118        self
119    }
120}
121
122impl<'a> CredentialCreateError for CredentialCreate<'a> {
123    fn _get_credential_type_error(&self) -> XRPLModelResult<()> {
124        super::validate_credential_type(&self.credential_type)
125    }
126
127    fn _get_uri_error(&self) -> XRPLModelResult<()> {
128        if let Some(uri) = &self.uri {
129            if uri.is_empty() {
130                return Err(XRPLModelException::ValueTooShort {
131                    field: "uri".into(),
132                    min: 1,
133                    found: 0,
134                });
135            }
136            if uri.len() > MAX_CREDENTIAL_URI_LENGTH {
137                return Err(XRPLModelException::ValueTooLong {
138                    field: "uri".into(),
139                    max: MAX_CREDENTIAL_URI_LENGTH,
140                    found: uri.len(),
141                });
142            }
143            if !uri.len().is_multiple_of(2) {
144                return Err(XRPLModelException::InvalidValueFormat {
145                    field: "uri".into(),
146                    format: "even-length hexadecimal (whole bytes)".into(),
147                    found: uri.as_ref().into(),
148                });
149            }
150            super::validate_hex("uri", uri)?;
151        }
152        Ok(())
153    }
154}
155
156pub trait CredentialCreateError {
157    fn _get_credential_type_error(&self) -> XRPLModelResult<()>;
158    fn _get_uri_error(&self) -> XRPLModelResult<()>;
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::models::{Model, XRPLModelException};
165    use alloc::borrow::Cow;
166    use alloc::format;
167    use proptest::prelude::*;
168
169    #[test]
170    fn test_serde() {
171        let default_txn = CredentialCreate {
172            common_fields: CommonFields {
173                account: "rIssuer111111111111111111111111111".into(),
174                transaction_type: TransactionType::CredentialCreate,
175                fee: Some("10".into()),
176                sequence: Some(7),
177                signing_pub_key: Some("".into()),
178                ..Default::default()
179            },
180            subject: "rSubject11111111111111111111111111".into(),
181            credential_type: "4B5943".into(),
182            expiration: Some(789004799),
183            uri: Some("69736162656C2E636F6D2F63726564656E7469616C732F6B79632F616C696365".into()),
184        };
185
186        let default_json_str = r#"{"Account":"rIssuer111111111111111111111111111","TransactionType":"CredentialCreate","Fee":"10","Flags":0,"Sequence":7,"SigningPubKey":"","Subject":"rSubject11111111111111111111111111","CredentialType":"4B5943","Expiration":789004799,"URI":"69736162656C2E636F6D2F63726564656E7469616C732F6B79632F616C696365"}"#;
187
188        let default_json_value = serde_json::to_value(default_json_str).unwrap();
189        let serialized_string = serde_json::to_string(&default_txn).unwrap();
190        let serialized_value = serde_json::to_value(&serialized_string).unwrap();
191        assert_eq!(serialized_value, default_json_value);
192
193        let deserialized: CredentialCreate = serde_json::from_str(default_json_str).unwrap();
194        assert_eq!(default_txn, deserialized);
195    }
196
197    #[test]
198    fn test_credential_type_empty_error() {
199        let tx = CredentialCreate {
200            common_fields: CommonFields {
201                account: "rIssuer111111111111111111111111111".into(),
202                transaction_type: TransactionType::CredentialCreate,
203                ..Default::default()
204            },
205            subject: "rSubject11111111111111111111111111".into(),
206            credential_type: Cow::from(""),
207            expiration: None,
208            uri: None,
209        };
210        assert_eq!(
211            tx.get_errors().unwrap_err(),
212            XRPLModelException::ValueTooShort {
213                field: "credential_type".into(),
214                min: 1,
215                found: 0,
216            }
217        );
218    }
219
220    #[test]
221    fn test_credential_type_non_hex_error() {
222        let tx = CredentialCreate {
223            common_fields: CommonFields {
224                account: "rIssuer111111111111111111111111111".into(),
225                transaction_type: TransactionType::CredentialCreate,
226                ..Default::default()
227            },
228            subject: "rSubject11111111111111111111111111".into(),
229            credential_type: "NOTHEX".into(), // even-length, but 'N','O','T' are non-hex
230            expiration: None,
231            uri: None,
232        };
233        assert_eq!(
234            tx.get_errors().unwrap_err(),
235            XRPLModelException::InvalidValueFormat {
236                field: "credential_type".into(),
237                format: "hexadecimal".into(),
238                found: "NOTHEX".into(),
239            }
240        );
241    }
242
243    #[test]
244    fn test_credential_type_odd_length_error() {
245        let tx = CredentialCreate {
246            common_fields: CommonFields {
247                account: "rIssuer111111111111111111111111111".into(),
248                transaction_type: TransactionType::CredentialCreate,
249                ..Default::default()
250            },
251            subject: "rSubject11111111111111111111111111".into(),
252            credential_type: "ABC".into(), // 3 chars — odd-length, valid hex chars
253            expiration: None,
254            uri: None,
255        };
256        assert_eq!(
257            tx.get_errors().unwrap_err(),
258            XRPLModelException::InvalidValueFormat {
259                field: "credential_type".into(),
260                format: "even-length hexadecimal (whole bytes)".into(),
261                found: "ABC".into(),
262            }
263        );
264    }
265
266    #[test]
267    fn test_credential_type_at_max_128_hex_chars_ok() {
268        // 128 hex chars = 64 bytes, the spec maximum
269        let max_hex: Cow<'_, str> = Cow::from("A".repeat(128));
270        let tx = CredentialCreate {
271            common_fields: CommonFields {
272                account: "rIssuer111111111111111111111111111".into(),
273                transaction_type: TransactionType::CredentialCreate,
274                ..Default::default()
275            },
276            subject: "rSubject11111111111111111111111111".into(),
277            credential_type: max_hex,
278            expiration: None,
279            uri: None,
280        };
281        assert!(tx.get_errors().is_ok());
282    }
283
284    #[test]
285    fn test_credential_type_exceeds_128_hex_chars_error() {
286        // 129 hex chars exceeds the limit
287        let too_long: Cow<'_, str> = Cow::from("A".repeat(129));
288        let tx = CredentialCreate {
289            common_fields: CommonFields {
290                account: "rIssuer111111111111111111111111111".into(),
291                transaction_type: TransactionType::CredentialCreate,
292                ..Default::default()
293            },
294            subject: "rSubject11111111111111111111111111".into(),
295            credential_type: too_long,
296            expiration: None,
297            uri: None,
298        };
299        assert_eq!(
300            tx.get_errors().unwrap_err(),
301            XRPLModelException::ValueTooLong {
302                field: "credential_type".into(),
303                max: 128,
304                found: 129,
305            }
306        );
307    }
308
309    #[test]
310    fn test_uri_at_max_256_hex_chars_ok() {
311        // 256 hex chars = 128 decoded bytes; both rippled and xrpl.js cap at 256 hex chars
312        let max_uri: Cow<'_, str> = Cow::from("A".repeat(MAX_CREDENTIAL_URI_LENGTH));
313        let tx = CredentialCreate {
314            common_fields: CommonFields {
315                account: "rIssuer111111111111111111111111111".into(),
316                transaction_type: TransactionType::CredentialCreate,
317                ..Default::default()
318            },
319            subject: "rSubject11111111111111111111111111".into(),
320            credential_type: "4B5943".into(),
321            expiration: None,
322            uri: Some(max_uri),
323        };
324        assert!(tx.get_errors().is_ok());
325    }
326
327    #[test]
328    fn test_uri_exceeds_256_hex_chars_error() {
329        let too_long: Cow<'_, str> = Cow::from("A".repeat(MAX_CREDENTIAL_URI_LENGTH + 1));
330        let tx = CredentialCreate {
331            common_fields: CommonFields {
332                account: "rIssuer111111111111111111111111111".into(),
333                transaction_type: TransactionType::CredentialCreate,
334                ..Default::default()
335            },
336            subject: "rSubject11111111111111111111111111".into(),
337            credential_type: "4B5943".into(),
338            expiration: None,
339            uri: Some(too_long),
340        };
341        assert_eq!(
342            tx.get_errors().unwrap_err(),
343            XRPLModelException::ValueTooLong {
344                field: "uri".into(),
345                max: MAX_CREDENTIAL_URI_LENGTH,
346                found: MAX_CREDENTIAL_URI_LENGTH + 1,
347            }
348        );
349    }
350
351    #[test]
352    fn test_uri_empty_error() {
353        // Spec section 3.2: "The URI field is empty" is a failure condition.
354        let tx = CredentialCreate {
355            common_fields: CommonFields {
356                account: "rIssuer111111111111111111111111111".into(),
357                transaction_type: TransactionType::CredentialCreate,
358                ..Default::default()
359            },
360            subject: "rSubject11111111111111111111111111".into(),
361            credential_type: "4B5943".into(),
362            expiration: None,
363            uri: Some(Cow::from("")),
364        };
365        assert_eq!(
366            tx.get_errors().unwrap_err(),
367            XRPLModelException::ValueTooShort {
368                field: "uri".into(),
369                min: 1,
370                found: 0,
371            }
372        );
373    }
374
375    #[test]
376    fn test_uri_odd_length_error() {
377        let tx = CredentialCreate {
378            common_fields: CommonFields {
379                account: "rIssuer111111111111111111111111111".into(),
380                transaction_type: TransactionType::CredentialCreate,
381                ..Default::default()
382            },
383            subject: "rSubject11111111111111111111111111".into(),
384            credential_type: "4B5943".into(),
385            expiration: None,
386            uri: Some(Cow::from("ABC")), // 3 chars — odd-length valid hex
387        };
388        assert_eq!(
389            tx.get_errors().unwrap_err(),
390            XRPLModelException::InvalidValueFormat {
391                field: "uri".into(),
392                format: "even-length hexadecimal (whole bytes)".into(),
393                found: "ABC".into(),
394            }
395        );
396    }
397
398    #[test]
399    fn test_uri_non_hex_error() {
400        let tx = CredentialCreate {
401            common_fields: CommonFields {
402                account: "rIssuer111111111111111111111111111".into(),
403                transaction_type: TransactionType::CredentialCreate,
404                ..Default::default()
405            },
406            subject: "rSubject11111111111111111111111111".into(),
407            credential_type: "4B5943".into(),
408            expiration: None,
409            uri: Some(Cow::from("NOTHEX")), // 6 chars, even-length, non-hex
410        };
411        assert_eq!(
412            tx.get_errors().unwrap_err(),
413            XRPLModelException::InvalidValueFormat {
414                field: "uri".into(),
415                format: "hexadecimal".into(),
416                found: "NOTHEX".into(),
417            }
418        );
419    }
420
421    #[test]
422    fn test_subject_same_as_account_self_issued_ok() {
423        // Per the spec, an issuer can issue a credential to themselves
424        let tx = CredentialCreate {
425            common_fields: CommonFields {
426                account: "rSelfIssuer1111111111111111111111".into(),
427                transaction_type: TransactionType::CredentialCreate,
428                ..Default::default()
429            },
430            subject: "rSelfIssuer1111111111111111111111".into(),
431            credential_type: "4B5943".into(),
432            expiration: None,
433            uri: None,
434        };
435        assert!(tx.get_errors().is_ok());
436    }
437
438    #[test]
439    fn test_valid_minimal_credential_create() {
440        let tx = CredentialCreate {
441            common_fields: CommonFields {
442                account: "rIssuer111111111111111111111111111".into(),
443                transaction_type: TransactionType::CredentialCreate,
444                ..Default::default()
445            },
446            subject: "rSubject11111111111111111111111111".into(),
447            credential_type: "AB".into(),
448            expiration: None,
449            uri: None,
450        };
451        assert!(tx.get_errors().is_ok());
452    }
453
454    #[test]
455    fn test_uri_none_ok() {
456        let tx = CredentialCreate {
457            common_fields: CommonFields {
458                account: "rIssuer111111111111111111111111111".into(),
459                transaction_type: TransactionType::CredentialCreate,
460                ..Default::default()
461            },
462            subject: "rSubject11111111111111111111111111".into(),
463            credential_type: "4B5943".into(),
464            expiration: None,
465            uri: None,
466        };
467        assert!(tx.get_errors().is_ok());
468    }
469
470    proptest! {
471        #![proptest_config(ProptestConfig::with_cases(200))]
472
473        #[test]
474        fn prop_credential_type_valid_length(len in 1_usize..=64) {
475            let ct = "AB".repeat(len); // even-length hex pairs — valid encodable bytes
476            let tx = CredentialCreate {
477                common_fields: CommonFields {
478                    account: "rIssuer111111111111111111111111111".into(),
479                    transaction_type: TransactionType::CredentialCreate,
480                    ..Default::default()
481                },
482                subject: "rSubject11111111111111111111111111".into(),
483                credential_type: Cow::Owned(ct),
484                expiration: None,
485                uri: None,
486            };
487            prop_assert!(tx.get_errors().is_ok(), "len {} should be valid", len);
488        }
489
490        #[test]
491        fn prop_credential_type_too_long(extra in 1_usize..=100) {
492            let len = 64 + extra; // "AB".repeat(64) = 128 chars (max); exceed that
493            let ct = "AB".repeat(len);
494            let tx = CredentialCreate {
495                common_fields: CommonFields {
496                    account: "rIssuer111111111111111111111111111".into(),
497                    transaction_type: TransactionType::CredentialCreate,
498                    ..Default::default()
499                },
500                subject: "rSubject11111111111111111111111111".into(),
501                credential_type: Cow::Owned(ct),
502                expiration: None,
503                uri: None,
504            };
505            prop_assert!(tx.get_errors().is_err(), "len {} should be rejected", len);
506        }
507
508        #[test]
509        fn prop_serde_roundtrip(
510            ct in "[0-9A-F]{2,128}",
511            has_expiration in proptest::bool::ANY,
512            expiration_val in proptest::num::u32::ANY,
513            has_uri in proptest::bool::ANY,
514            uri_hex in "[0-9A-F]{2,200}",
515        ) {
516            let tx = CredentialCreate {
517                common_fields: CommonFields {
518                    account: "rIssuer111111111111111111111111111".into(),
519                    transaction_type: TransactionType::CredentialCreate,
520                    fee: Some("12".into()),
521                    sequence: Some(42),
522                    signing_pub_key: Some(Cow::Borrowed("")),
523                    ..Default::default()
524                },
525                subject: "rSubject11111111111111111111111111".into(),
526                credential_type: Cow::Owned(ct),
527                expiration: if has_expiration { Some(expiration_val) } else { None },
528                uri: if has_uri { Some(Cow::Owned(uri_hex)) } else { None },
529            };
530            let json = serde_json::to_string(&tx)
531                .map_err(|e| TestCaseError::fail(format!("serialize: {e}")))?;
532            let rt: CredentialCreate = serde_json::from_str(&json)
533                .map_err(|e| TestCaseError::fail(format!("deserialize: {e}")))?;
534            prop_assert_eq!(&tx, &rt);
535        }
536    }
537}