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#[skip_serializing_none]
22#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq, Clone)]
23#[serde(rename_all = "PascalCase")]
24pub struct CredentialCreate<'a> {
25 #[serde(flatten)]
27 pub common_fields: CommonFields<'a, NoFlags>,
28 pub subject: Cow<'a, str>,
30 pub credential_type: Cow<'a, str>,
32 pub expiration: Option<u32>,
34 #[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(), 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(), 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 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 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 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 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")), };
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")), };
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 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); 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; 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}