Skip to main content

xrpl/models/transactions/
did_set.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::{
8    transactions::{Memo, Signer, Transaction, TransactionType},
9    Model, XRPLModelResult,
10};
11use crate::models::{FlagCollection, NoFlags};
12
13use super::{
14    exceptions::{XRPLDIDSetException, XRPLTransactionException},
15    CommonFields, CommonTransactionBuilder,
16};
17
18/// Maximum length in hex characters for DID fields (Data, DIDDocument, URI).
19/// Each field is limited to 256 bytes on the ledger. Since values are
20/// hex-encoded in JSON (2 hex characters per byte), the limit is 512
21/// hex characters.
22pub const MAX_DID_FIELD_LENGTH: usize = 512;
23
24/// Create or update a DID (Decentralized Identifier) associated with
25/// the sending account.
26///
27/// See DIDSet:
28/// `<https://xrpl.org/docs/references/protocol/transactions/types/didset>`
29#[skip_serializing_none]
30#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq, Clone)]
31#[serde(rename_all = "PascalCase")]
32pub struct DIDSet<'a> {
33    /// The base fields for all transaction models.
34    ///
35    /// See Transaction Common Fields:
36    /// `<https://xrpl.org/transaction-common-fields.html>`
37    #[serde(flatten)]
38    pub common_fields: CommonFields<'a, NoFlags>,
39    /// The public attestations of identity credentials associated with the DID.
40    pub data: Option<Cow<'a, str>>,
41    /// The DID document associated with the DID.
42    #[serde(rename = "DIDDocument")]
43    pub did_document: Option<Cow<'a, str>>,
44    /// The Universal Resource Identifier associated with the DID.
45    #[serde(rename = "URI")]
46    pub uri: Option<Cow<'a, str>>,
47}
48
49impl<'a> Model for DIDSet<'a> {
50    fn get_errors(&self) -> XRPLModelResult<()> {
51        self.get_did_field_errors()
52    }
53}
54
55impl<'a> Transaction<'a, NoFlags> for DIDSet<'a> {
56    fn get_transaction_type(&self) -> &TransactionType {
57        self.common_fields.get_transaction_type()
58    }
59
60    fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> {
61        self.common_fields.get_common_fields()
62    }
63
64    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
65        self.common_fields.get_mut_common_fields()
66    }
67}
68
69impl<'a> CommonTransactionBuilder<'a, NoFlags> for DIDSet<'a> {
70    fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> {
71        &mut self.common_fields
72    }
73
74    fn into_self(self) -> Self {
75        self
76    }
77}
78
79/// Returns true if the string is valid hexadecimal (only chars 0-9, a-f, A-F).
80/// An empty string is considered valid hex.
81fn is_hex(s: &str) -> bool {
82    s.chars().all(|c| c.is_ascii_hexdigit())
83}
84
85impl<'a> DIDSet<'a> {
86    pub fn new(
87        account: Cow<'a, str>,
88        account_txn_id: Option<Cow<'a, str>>,
89        fee: Option<XRPAmount<'a>>,
90        last_ledger_sequence: Option<u32>,
91        memos: Option<Vec<Memo>>,
92        sequence: Option<u32>,
93        signers: Option<Vec<Signer>>,
94        source_tag: Option<u32>,
95        ticket_sequence: Option<u32>,
96        data: Option<Cow<'a, str>>,
97        did_document: Option<Cow<'a, str>>,
98        uri: Option<Cow<'a, str>>,
99    ) -> Self {
100        Self {
101            common_fields: CommonFields::new(
102                account,
103                TransactionType::DIDSet,
104                account_txn_id,
105                fee,
106                Some(FlagCollection::default()),
107                last_ledger_sequence,
108                memos,
109                None,
110                sequence,
111                signers,
112                None,
113                source_tag,
114                ticket_sequence,
115                None,
116            ),
117            data,
118            did_document,
119            uri,
120        }
121    }
122
123    /// Validate the DID-specific fields.
124    fn get_did_field_errors(&self) -> XRPLModelResult<()> {
125        // At least one of data, did_document, uri must be provided
126        if self.data.is_none() && self.did_document.is_none() && self.uri.is_none() {
127            return Err(XRPLTransactionException::from(
128                XRPLDIDSetException::MustHaveAtLeastOneField,
129            )
130            .into());
131        }
132
133        // If all provided fields are empty strings, that's invalid
134        let all_empty = self.data.as_deref().is_none_or(|s| s.is_empty())
135            && self.did_document.as_deref().is_none_or(|s| s.is_empty())
136            && self.uri.as_deref().is_none_or(|s| s.is_empty());
137
138        // Only check "all empty" if at least one field IS provided (we already checked all-None above)
139        if all_empty {
140            return Err(XRPLTransactionException::from(
141                XRPLDIDSetException::AtLeastOneFieldMustBeNonEmpty,
142            )
143            .into());
144        }
145
146        // Validate each field individually
147        self.validate_did_field("data", self.data.as_deref())?;
148        self.validate_did_field("did_document", self.did_document.as_deref())?;
149        self.validate_did_field("uri", self.uri.as_deref())?;
150
151        Ok(())
152    }
153
154    /// Validate a single DID field for hex format and max length.
155    fn validate_did_field(&self, field_name: &str, value: Option<&str>) -> XRPLModelResult<()> {
156        if let Some(val) = value {
157            if val.is_empty() {
158                // Empty string is valid (used to delete a field)
159                return Ok(());
160            }
161
162            let valid_hex = is_hex(val);
163            let valid_length = val.len() <= MAX_DID_FIELD_LENGTH;
164
165            if !valid_hex && !valid_length {
166                return Err(XRPLTransactionException::from(
167                    XRPLDIDSetException::InvalidFieldHexAndTooLong {
168                        field: field_name.into(),
169                        found_length: val.len(),
170                    },
171                )
172                .into());
173            }
174
175            if !valid_hex {
176                return Err(
177                    XRPLTransactionException::from(XRPLDIDSetException::InvalidFieldHex {
178                        field: field_name.into(),
179                    })
180                    .into(),
181                );
182            }
183
184            if !valid_length {
185                return Err(
186                    XRPLTransactionException::from(XRPLDIDSetException::FieldTooLong {
187                        field: field_name.into(),
188                        max: MAX_DID_FIELD_LENGTH,
189                        found: val.len(),
190                    })
191                    .into(),
192                );
193            }
194        }
195
196        Ok(())
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    const ACCOUNT: &str = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ";
205    const VALID_FIELD: &str = "1234567890abcdefABCDEF";
206    const TOO_LONG_FIELD: &str = concat!(
207        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
208        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
209        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
210        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
211        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
212        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
213        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
214        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
215        "A"
216    ); // 513 hex chars (> 512 limit)
217    const BAD_HEX_FIELD: &str = "random_non_hex_content";
218
219    #[test]
220    fn test_valid_all_fields() {
221        let tx = DIDSet {
222            common_fields: CommonFields {
223                account: ACCOUNT.into(),
224                transaction_type: TransactionType::DIDSet,
225                ..Default::default()
226            },
227            data: Some(VALID_FIELD.into()),
228            did_document: Some(VALID_FIELD.into()),
229            uri: Some(VALID_FIELD.into()),
230        };
231        assert!(tx.is_valid());
232    }
233
234    #[test]
235    fn test_valid_only_data() {
236        let tx = DIDSet {
237            common_fields: CommonFields {
238                account: ACCOUNT.into(),
239                transaction_type: TransactionType::DIDSet,
240                ..Default::default()
241            },
242            data: Some(VALID_FIELD.into()),
243            did_document: None,
244            uri: None,
245        };
246        assert!(tx.is_valid());
247    }
248
249    #[test]
250    fn test_valid_only_did_document() {
251        let tx = DIDSet {
252            common_fields: CommonFields {
253                account: ACCOUNT.into(),
254                transaction_type: TransactionType::DIDSet,
255                ..Default::default()
256            },
257            data: None,
258            did_document: Some(VALID_FIELD.into()),
259            uri: None,
260        };
261        assert!(tx.is_valid());
262    }
263
264    #[test]
265    fn test_valid_only_uri() {
266        let tx = DIDSet {
267            common_fields: CommonFields {
268                account: ACCOUNT.into(),
269                transaction_type: TransactionType::DIDSet,
270                ..Default::default()
271            },
272            data: None,
273            did_document: None,
274            uri: Some(VALID_FIELD.into()),
275        };
276        assert!(tx.is_valid());
277    }
278
279    #[test]
280    fn test_empty_no_fields() {
281        let tx = DIDSet {
282            common_fields: CommonFields {
283                account: ACCOUNT.into(),
284                transaction_type: TransactionType::DIDSet,
285                ..Default::default()
286            },
287            data: None,
288            did_document: None,
289            uri: None,
290        };
291        assert!(!tx.is_valid());
292        assert!(tx.get_errors().is_err());
293    }
294
295    #[test]
296    fn test_all_empty_strings() {
297        let tx = DIDSet {
298            common_fields: CommonFields {
299                account: ACCOUNT.into(),
300                transaction_type: TransactionType::DIDSet,
301                ..Default::default()
302            },
303            data: Some("".into()),
304            did_document: Some("".into()),
305            uri: Some("".into()),
306        };
307        assert!(!tx.is_valid());
308        assert!(tx.get_errors().is_err());
309    }
310
311    #[test]
312    fn test_single_empty_data_field_is_valid() {
313        // An empty string for one field is valid (used to delete that field)
314        // as long as at least one other field is non-empty or not all are empty
315        let tx = DIDSet {
316            common_fields: CommonFields {
317                account: ACCOUNT.into(),
318                transaction_type: TransactionType::DIDSet,
319                ..Default::default()
320            },
321            data: Some("".into()),
322            did_document: Some(VALID_FIELD.into()),
323            uri: None,
324        };
325        assert!(tx.is_valid());
326    }
327
328    #[test]
329    fn test_too_long() {
330        let tx = DIDSet {
331            common_fields: CommonFields {
332                account: ACCOUNT.into(),
333                transaction_type: TransactionType::DIDSet,
334                ..Default::default()
335            },
336            data: None,
337            did_document: Some(TOO_LONG_FIELD.into()),
338            uri: None,
339        };
340        assert!(!tx.is_valid());
341        assert!(tx.get_errors().is_err());
342    }
343
344    #[test]
345    fn test_not_hex() {
346        let tx = DIDSet {
347            common_fields: CommonFields {
348                account: ACCOUNT.into(),
349                transaction_type: TransactionType::DIDSet,
350                ..Default::default()
351            },
352            data: Some(BAD_HEX_FIELD.into()),
353            did_document: None,
354            uri: None,
355        };
356        assert!(!tx.is_valid());
357        assert!(tx.get_errors().is_err());
358    }
359
360    #[test]
361    fn test_too_long_and_not_hex() {
362        // 513 non-hex chars
363        let bad_field: alloc::string::String = "q".repeat(513);
364        let tx = DIDSet {
365            common_fields: CommonFields {
366                account: ACCOUNT.into(),
367                transaction_type: TransactionType::DIDSet,
368                ..Default::default()
369            },
370            data: None,
371            did_document: None,
372            uri: Some(bad_field.into()),
373        };
374        assert!(!tx.is_valid());
375        assert!(tx.get_errors().is_err());
376    }
377
378    #[test]
379    fn test_serialize() {
380        let tx = DIDSet {
381            common_fields: CommonFields {
382                account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(),
383                transaction_type: TransactionType::DIDSet,
384                fee: Some("10".into()),
385                sequence: Some(391),
386                signing_pub_key: Some(
387                    "0330E7FC9D56BB25D6893BA3F317AE5BCF33B3291BD63DB32654A313222F7FD020".into(),
388                ),
389                ..Default::default()
390            },
391            data: Some("617474657374".into()),
392            did_document: Some("646F63".into()),
393            uri: Some("6469645F6578616D706C65".into()),
394        };
395
396        let expected_json = r#"{"Account":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","TransactionType":"DIDSet","Fee":"10","Flags":0,"Sequence":391,"SigningPubKey":"0330E7FC9D56BB25D6893BA3F317AE5BCF33B3291BD63DB32654A313222F7FD020","Data":"617474657374","DIDDocument":"646F63","URI":"6469645F6578616D706C65"}"#;
397
398        let serialized = serde_json::to_string(&tx).unwrap();
399        let expected_value = serde_json::to_value(expected_json).unwrap();
400        let serialized_value = serde_json::to_value(&serialized).unwrap();
401        assert_eq!(serialized_value, expected_value);
402
403        let deserialized: DIDSet = serde_json::from_str(expected_json).unwrap();
404        assert_eq!(tx, deserialized);
405    }
406
407    #[test]
408    fn test_builder_pattern() {
409        let tx = DIDSet {
410            common_fields: CommonFields {
411                account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(),
412                transaction_type: TransactionType::DIDSet,
413                ..Default::default()
414            },
415            data: Some("617474657374".into()),
416            did_document: Some("646F63".into()),
417            uri: Some("6469645F6578616D706C65".into()),
418        }
419        .with_fee("10".into())
420        .with_sequence(391)
421        .with_last_ledger_sequence(7108682);
422
423        assert_eq!(tx.data.as_deref(), Some("617474657374"));
424        assert_eq!(tx.did_document.as_deref(), Some("646F63"));
425        assert_eq!(tx.uri.as_deref(), Some("6469645F6578616D706C65"));
426        assert_eq!(tx.common_fields.fee.as_ref().unwrap().0, "10");
427        assert_eq!(tx.common_fields.sequence, Some(391));
428        assert_eq!(tx.common_fields.last_ledger_sequence, Some(7108682));
429    }
430
431    #[test]
432    fn test_default() {
433        let tx = DIDSet {
434            common_fields: CommonFields {
435                account: ACCOUNT.into(),
436                transaction_type: TransactionType::DIDSet,
437                ..Default::default()
438            },
439            data: Some(VALID_FIELD.into()),
440            ..Default::default()
441        };
442
443        assert_eq!(tx.common_fields.account, ACCOUNT);
444        assert_eq!(tx.common_fields.transaction_type, TransactionType::DIDSet);
445        assert_eq!(tx.data.as_deref(), Some(VALID_FIELD));
446        assert!(tx.did_document.is_none());
447        assert!(tx.uri.is_none());
448    }
449
450    #[test]
451    fn test_max_length_exactly_512() {
452        let max_field: alloc::string::String = "A".repeat(512);
453        let tx = DIDSet {
454            common_fields: CommonFields {
455                account: ACCOUNT.into(),
456                transaction_type: TransactionType::DIDSet,
457                ..Default::default()
458            },
459            data: Some(max_field.into()),
460            did_document: None,
461            uri: None,
462        };
463        assert!(tx.is_valid());
464    }
465}