Skip to main content

xrpl/models/transactions/
credential_delete.rs

1use alloc::borrow::Cow;
2use alloc::vec::Vec;
3use serde::{Deserialize, Serialize};
4use serde_with::skip_serializing_none;
5
6use crate::models::amount::XRPAmount;
7use crate::models::transactions::CommonFields;
8use crate::models::{
9    transactions::{Memo, Signer, Transaction, TransactionType},
10    Model, XRPLModelException, XRPLModelResult,
11};
12use crate::models::{FlagCollection, NoFlags};
13
14use super::CommonTransactionBuilder;
15
16/// A CredentialDelete transaction deletes a credential object.
17///
18/// See CredentialDelete:
19/// `<https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0070-credentials>`
20#[skip_serializing_none]
21#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq, Clone)]
22#[serde(rename_all = "PascalCase")]
23pub struct CredentialDelete<'a> {
24    /// The base fields for all transaction models.
25    #[serde(flatten)]
26    pub common_fields: CommonFields<'a, NoFlags>,
27    /// The subject of the credential. At least one of `subject` or `issuer` must
28    /// be provided. When omitted, Account implicitly fills the subject role.
29    pub subject: Option<Cow<'a, str>>,
30    /// The issuer of the credential. At least one of `subject` or `issuer` must
31    /// be provided. When omitted, Account implicitly fills the issuer role.
32    pub issuer: Option<Cow<'a, str>>,
33    /// A hex-encoded value identifying the credential type from this issuer.
34    pub credential_type: Cow<'a, str>,
35}
36
37impl<'a> Model for CredentialDelete<'a> {
38    fn get_errors(&self) -> XRPLModelResult<()> {
39        self._get_subject_or_issuer_error()?;
40        self._get_credential_type_error()
41    }
42}
43
44impl<'a> Transaction<'a, NoFlags> for CredentialDelete<'a> {
45    fn get_transaction_type(&self) -> &TransactionType {
46        self.common_fields.get_transaction_type()
47    }
48
49    fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> {
50        self.common_fields.get_common_fields()
51    }
52
53    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
54        self.common_fields.get_mut_common_fields()
55    }
56}
57
58impl<'a> CommonTransactionBuilder<'a, NoFlags> for CredentialDelete<'a> {
59    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
60        &mut self.common_fields
61    }
62
63    fn into_self(self) -> Self {
64        self
65    }
66}
67
68impl<'a> CredentialDelete<'a> {
69    #[allow(clippy::too_many_arguments)]
70    pub fn new(
71        account: Cow<'a, str>,
72        account_txn_id: Option<Cow<'a, str>>,
73        fee: Option<XRPAmount<'a>>,
74        last_ledger_sequence: Option<u32>,
75        memos: Option<Vec<Memo>>,
76        sequence: Option<u32>,
77        signers: Option<Vec<Signer>>,
78        source_tag: Option<u32>,
79        ticket_sequence: Option<u32>,
80        subject: Option<Cow<'a, str>>,
81        issuer: Option<Cow<'a, str>>,
82        credential_type: Cow<'a, str>,
83    ) -> Self {
84        Self {
85            common_fields: CommonFields::new(
86                account,
87                TransactionType::CredentialDelete,
88                account_txn_id,
89                fee,
90                Some(FlagCollection::default()),
91                last_ledger_sequence,
92                memos,
93                None,
94                sequence,
95                signers,
96                None,
97                source_tag,
98                ticket_sequence,
99                None,
100            ),
101            subject,
102            issuer,
103            credential_type,
104        }
105    }
106}
107
108impl<'a> CredentialDeleteError for CredentialDelete<'a> {
109    fn _get_subject_or_issuer_error(&self) -> XRPLModelResult<()> {
110        if self.subject.is_none() && self.issuer.is_none() {
111            return Err(XRPLModelException::ExpectedOneOf(&["subject", "issuer"]));
112        }
113        // When only one is provided, Account implicitly fills the other role.
114        // When both are explicitly provided, rippled decides whether Account may delete
115        // the credential from ledger state: issuer/subject can delete active credentials,
116        // and any account can delete expired credentials. The model cannot know whether
117        // the on-ledger credential is expired, so match xrpl.js and do not reject third-
118        // party submitters here.
119        Ok(())
120    }
121
122    fn _get_credential_type_error(&self) -> XRPLModelResult<()> {
123        super::validate_credential_type(&self.credential_type)
124    }
125}
126
127pub trait CredentialDeleteError {
128    fn _get_subject_or_issuer_error(&self) -> XRPLModelResult<()>;
129    fn _get_credential_type_error(&self) -> XRPLModelResult<()>;
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::models::{Model, XRPLModelException};
136    use alloc::borrow::Cow;
137    use alloc::format;
138    use proptest::prelude::*;
139
140    #[test]
141    fn test_requires_subject_or_issuer() {
142        let tx = CredentialDelete {
143            common_fields: CommonFields {
144                account: "rSubmitter111111111111111111111111".into(),
145                transaction_type: TransactionType::CredentialDelete,
146                ..Default::default()
147            },
148            subject: None,
149            issuer: None,
150            credential_type: "4B5943".into(),
151        };
152        assert!(tx.get_errors().is_err());
153    }
154
155    #[test]
156    fn test_valid_with_subject() {
157        let tx = CredentialDelete {
158            common_fields: CommonFields {
159                account: "rSubject11111111111111111111111111".into(),
160                transaction_type: TransactionType::CredentialDelete,
161                ..Default::default()
162            },
163            subject: Some("rSubject11111111111111111111111111".into()),
164            issuer: None,
165            credential_type: "4B5943".into(),
166        };
167        assert!(tx.get_errors().is_ok());
168    }
169
170    #[test]
171    fn test_both_provided_allows_third_party_submitter() {
172        // Match xrpl.js and the spec: when both Subject and Issuer are provided,
173        // rippled may allow a third-party Account to delete an expired credential.
174        // Model validation cannot know the credential's expiration state.
175        let tx = CredentialDelete {
176            common_fields: CommonFields {
177                account: "rSubmitter111111111111111111111111".into(),
178                transaction_type: TransactionType::CredentialDelete,
179                ..Default::default()
180            },
181            subject: Some("rSubject11111111111111111111111111".into()),
182            issuer: Some("rIssuer111111111111111111111111111".into()),
183            credential_type: "4B5943".into(),
184        };
185        assert!(tx.get_errors().is_ok());
186    }
187
188    #[test]
189    fn test_valid_with_issuer_only() {
190        // When only issuer is provided, account implicitly fills the subject role.
191        let tx = CredentialDelete {
192            common_fields: CommonFields {
193                account: "rIssuer111111111111111111111111111".into(),
194                transaction_type: TransactionType::CredentialDelete,
195                ..Default::default()
196            },
197            subject: None,
198            issuer: Some("rIssuer111111111111111111111111111".into()),
199            credential_type: "4B5943".into(),
200        };
201        assert!(tx.get_errors().is_ok());
202    }
203
204    #[test]
205    fn test_valid_subject_only_account_is_implicit_issuer() {
206        // When only subject is provided, account implicitly fills the issuer role.
207        // Account does NOT need to match subject — this was the bug.
208        let tx = CredentialDelete {
209            common_fields: CommonFields {
210                account: "rSubmitter111111111111111111111111".into(),
211                transaction_type: TransactionType::CredentialDelete,
212                ..Default::default()
213            },
214            subject: Some("rSubject11111111111111111111111111".into()),
215            issuer: None,
216            credential_type: "4B5943".into(),
217        };
218        assert!(tx.get_errors().is_ok());
219    }
220
221    #[test]
222    fn test_valid_issuer_only_account_is_implicit_subject() {
223        // Account does NOT need to match issuer when subject is omitted.
224        let tx = CredentialDelete {
225            common_fields: CommonFields {
226                account: "rSubmitter111111111111111111111111".into(),
227                transaction_type: TransactionType::CredentialDelete,
228                ..Default::default()
229            },
230            subject: None,
231            issuer: Some("rIssuer111111111111111111111111111".into()),
232            credential_type: "4B5943".into(),
233        };
234        assert!(tx.get_errors().is_ok());
235    }
236
237    #[test]
238    fn test_valid_both_provided_account_matches_subject() {
239        let tx = CredentialDelete {
240            common_fields: CommonFields {
241                account: "rSubject11111111111111111111111111".into(),
242                transaction_type: TransactionType::CredentialDelete,
243                ..Default::default()
244            },
245            subject: Some("rSubject11111111111111111111111111".into()),
246            issuer: Some("rIssuer111111111111111111111111111".into()),
247            credential_type: "4B5943".into(),
248        };
249        assert!(tx.get_errors().is_ok());
250    }
251
252    #[test]
253    fn test_valid_both_provided_account_matches_issuer() {
254        let tx = CredentialDelete {
255            common_fields: CommonFields {
256                account: "rIssuer111111111111111111111111111".into(),
257                transaction_type: TransactionType::CredentialDelete,
258                ..Default::default()
259            },
260            subject: Some("rSubject11111111111111111111111111".into()),
261            issuer: Some("rIssuer111111111111111111111111111".into()),
262            credential_type: "4B5943".into(),
263        };
264        assert!(tx.get_errors().is_ok());
265    }
266
267    #[test]
268    fn test_serde() {
269        let default_txn = CredentialDelete {
270            common_fields: CommonFields {
271                account: "rSubmitter111111111111111111111111".into(),
272                transaction_type: TransactionType::CredentialDelete,
273                fee: Some("10".into()),
274                sequence: Some(9),
275                signing_pub_key: Some("".into()),
276                ..Default::default()
277            },
278            subject: Some("rSubject11111111111111111111111111".into()),
279            issuer: None,
280            credential_type: "4B5943".into(),
281        };
282
283        let default_json_str = r#"{"Account":"rSubmitter111111111111111111111111","TransactionType":"CredentialDelete","Fee":"10","Flags":0,"Sequence":9,"SigningPubKey":"","Subject":"rSubject11111111111111111111111111","CredentialType":"4B5943"}"#;
284
285        let default_json_value = serde_json::to_value(default_json_str).unwrap();
286        let serialized_string = serde_json::to_string(&default_txn).unwrap();
287        let serialized_value = serde_json::to_value(&serialized_string).unwrap();
288        assert_eq!(serialized_value, default_json_value);
289
290        let deserialized: CredentialDelete = serde_json::from_str(default_json_str).unwrap();
291        assert_eq!(default_txn, deserialized);
292    }
293
294    #[test]
295    fn test_credential_type_empty_error() {
296        let tx = CredentialDelete {
297            common_fields: CommonFields {
298                account: "rSubject11111111111111111111111111".into(),
299                transaction_type: TransactionType::CredentialDelete,
300                ..Default::default()
301            },
302            subject: Some("rSubject11111111111111111111111111".into()),
303            issuer: None,
304            credential_type: Cow::from(""),
305        };
306        assert_eq!(
307            tx.get_errors().unwrap_err(),
308            XRPLModelException::ValueTooShort {
309                field: "credential_type".into(),
310                min: 1,
311                found: 0,
312            }
313        );
314    }
315
316    #[test]
317    fn test_credential_type_non_hex_error() {
318        let tx = CredentialDelete {
319            common_fields: CommonFields {
320                account: "rSubject11111111111111111111111111".into(),
321                transaction_type: TransactionType::CredentialDelete,
322                ..Default::default()
323            },
324            subject: Some("rSubject11111111111111111111111111".into()),
325            issuer: None,
326            credential_type: "NOTHEX".into(),
327        };
328        assert_eq!(
329            tx.get_errors().unwrap_err(),
330            XRPLModelException::InvalidValueFormat {
331                field: "credential_type".into(),
332                format: "hexadecimal".into(),
333                found: "NOTHEX".into(),
334            }
335        );
336    }
337
338    #[test]
339    fn test_credential_type_exceeds_128_hex_chars_error() {
340        let too_long: Cow<'_, str> = Cow::from("A".repeat(129));
341        let tx = CredentialDelete {
342            common_fields: CommonFields {
343                account: "rSubject11111111111111111111111111".into(),
344                transaction_type: TransactionType::CredentialDelete,
345                ..Default::default()
346            },
347            subject: Some("rSubject11111111111111111111111111".into()),
348            issuer: None,
349            credential_type: too_long,
350        };
351        assert_eq!(
352            tx.get_errors().unwrap_err(),
353            XRPLModelException::ValueTooLong {
354                field: "credential_type".into(),
355                max: 128,
356                found: 129,
357            }
358        );
359    }
360
361    #[test]
362    fn test_credential_type_at_max_128_ok() {
363        let max_hex: Cow<'_, str> = Cow::from("A".repeat(128));
364        let tx = CredentialDelete {
365            common_fields: CommonFields {
366                account: "rSubject11111111111111111111111111".into(),
367                transaction_type: TransactionType::CredentialDelete,
368                ..Default::default()
369            },
370            subject: Some("rSubject11111111111111111111111111".into()),
371            issuer: None,
372            credential_type: max_hex,
373        };
374        assert!(tx.get_errors().is_ok());
375    }
376
377    #[test]
378    fn test_self_issued_credential_delete_both_subject_and_issuer_equal_account() {
379        // Self-issued credential: Account is both the subject and the issuer
380        let tx = CredentialDelete {
381            common_fields: CommonFields {
382                account: "rSelfIssuer1111111111111111111111".into(),
383                transaction_type: TransactionType::CredentialDelete,
384                ..Default::default()
385            },
386            subject: Some("rSelfIssuer1111111111111111111111".into()),
387            issuer: Some("rSelfIssuer1111111111111111111111".into()),
388            credential_type: "4B5943".into(),
389        };
390        assert!(tx.get_errors().is_ok());
391    }
392
393    const ACCOUNTS: [&str; 3] = [
394        "rU4EE1FskCPJw5QkLx1iGgdWiJa6HeqYyb",
395        "rEhxGqkqPPSxQ3P25J66ft5TwpzV14k2de",
396        "rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH",
397    ];
398
399    proptest! {
400        #![proptest_config(ProptestConfig::with_cases(200))]
401
402        #[test]
403        fn prop_subject_only_any_submitter_valid(acct_idx in 0_usize..3) {
404            let tx = CredentialDelete {
405                common_fields: CommonFields {
406                    account: ACCOUNTS[acct_idx].into(),
407                    transaction_type: TransactionType::CredentialDelete,
408                    ..Default::default()
409                },
410                subject: Some(ACCOUNTS[1].into()),
411                issuer: None,
412                credential_type: "4B5943".into(),
413            };
414            prop_assert!(tx.get_errors().is_ok());
415        }
416
417        #[test]
418        fn prop_issuer_only_any_submitter_valid(acct_idx in 0_usize..3) {
419            let tx = CredentialDelete {
420                common_fields: CommonFields {
421                    account: ACCOUNTS[acct_idx].into(),
422                    transaction_type: TransactionType::CredentialDelete,
423                    ..Default::default()
424                },
425                subject: None,
426                issuer: Some(ACCOUNTS[0].into()),
427                credential_type: "4B5943".into(),
428            };
429            prop_assert!(tx.get_errors().is_ok());
430        }
431
432        #[test]
433        fn prop_both_any_submitter_valid(acct_idx in 0_usize..3) {
434            let tx = CredentialDelete {
435                common_fields: CommonFields {
436                    account: ACCOUNTS[acct_idx].into(),
437                    transaction_type: TransactionType::CredentialDelete,
438                    ..Default::default()
439                },
440                subject: Some(ACCOUNTS[0].into()),
441                issuer: Some(ACCOUNTS[1].into()),
442                credential_type: "4B5943".into(),
443            };
444            prop_assert!(tx.get_errors().is_ok());
445        }
446
447        #[test]
448        fn prop_serde_roundtrip(
449            ct in "([0-9A-F]{2}){1,64}", // even-length only: 2, 4, ..., 128 hex chars
450            has_subject in proptest::bool::ANY,
451            has_issuer in proptest::bool::ANY,
452        ) {
453            let subject = if has_subject || !has_issuer { Some(Cow::Borrowed(ACCOUNTS[0])) } else { None };
454            let issuer = if has_issuer { Some(Cow::Borrowed(ACCOUNTS[1])) } else { None };
455            let acct = if subject.is_some() { ACCOUNTS[0] } else { ACCOUNTS[1] };
456            let tx = CredentialDelete {
457                common_fields: CommonFields {
458                    account: acct.into(),
459                    transaction_type: TransactionType::CredentialDelete,
460                    fee: Some("10".into()),
461                    sequence: Some(7),
462                    signing_pub_key: Some(Cow::Borrowed("")),
463                    ..Default::default()
464                },
465                subject,
466                issuer,
467                credential_type: Cow::Owned(ct),
468            };
469            prop_assert!(tx.get_errors().is_ok());
470            let json = serde_json::to_string(&tx)
471                .map_err(|e| TestCaseError::fail(format!("serialize: {e}")))?;
472            let rt: CredentialDelete = serde_json::from_str(&json)
473                .map_err(|e| TestCaseError::fail(format!("deserialize: {e}")))?;
474            prop_assert_eq!(&tx, &rt);
475        }
476    }
477}