From 121429217e36a10f4d4b657380cdf96b59fc366b Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Fri, 10 Apr 2026 14:13:06 -0700 Subject: [PATCH 1/4] Support for DID feature in xrpl-rust --- CHANGELOG.md | 1 + src/asynch/clients/async_client.rs | 2 +- src/models/exceptions.rs | 12 +- src/models/ledger/objects/did.rs | 137 ++++++++ src/models/ledger/objects/mod.rs | 4 + src/models/requests/account_objects.rs | 3 + src/models/results/server_state.rs | 2 + src/models/transactions/did_delete.rs | 172 +++++++++ src/models/transactions/did_set.rs | 468 +++++++++++++++++++++++++ src/models/transactions/exceptions.rs | 23 ++ src/models/transactions/mod.rs | 5 + tests/common/mod.rs | 12 +- tests/transactions/did_delete.rs | 147 ++++++++ tests/transactions/did_set.rs | 200 +++++++++++ tests/transactions/mod.rs | 2 + 15 files changed, 1184 insertions(+), 6 deletions(-) create mode 100644 src/models/ledger/objects/did.rs create mode 100644 src/models/transactions/did_delete.rs create mode 100644 src/models/transactions/did_set.rs create mode 100644 tests/transactions/did_delete.rs create mode 100644 tests/transactions/did_set.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b985b43..c9953761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [[Unreleased]] ### Added +- Support for Decentralized Identity (DID, Amendment ID: DB432C3A09D9D5DFC7859F39AE5FF767ABC59AED0A9FB441E83B814D8946C109) ### Fixed diff --git a/src/asynch/clients/async_client.rs b/src/asynch/clients/async_client.rs index ce9781e3..f32095b1 100644 --- a/src/asynch/clients/async_client.rs +++ b/src/asynch/clients/async_client.rs @@ -17,7 +17,7 @@ pub trait XRPLAsyncClient: XRPLClient { let server_state = self.request(ServerState::new(None).into()).await?; let server_state: ServerStateResult = server_state.try_into()?; let common_fields = CommonFields { - network_id: None, // TODO Server state has no network ID. + network_id: server_state.state.network_id, build_version: Some(server_state.state.build_version), }; diff --git a/src/models/exceptions.rs b/src/models/exceptions.rs index ccbc80e5..24b416a4 100644 --- a/src/models/exceptions.rs +++ b/src/models/exceptions.rs @@ -10,9 +10,9 @@ use crate::XRPLSerdeJsonError; use super::{ results::exceptions::XRPLResultException, transactions::exceptions::{ - XRPLAccountSetException, XRPLNFTokenCancelOfferException, XRPLNFTokenCreateOfferException, - XRPLPaymentException, XRPLSignerListSetException, XRPLTransactionException, - XRPLXChainClaimException, XRPLXChainCreateBridgeException, + XRPLAccountSetException, XRPLDIDSetException, XRPLNFTokenCancelOfferException, + XRPLNFTokenCreateOfferException, XRPLPaymentException, XRPLSignerListSetException, + XRPLTransactionException, XRPLXChainClaimException, XRPLXChainCreateBridgeException, XRPLXChainCreateClaimIDException, XRPLXChainModifyBridgeException, }, }; @@ -105,6 +105,12 @@ impl From for XRPLModelException { } } +impl From for XRPLModelException { + fn from(error: XRPLDIDSetException) -> Self { + XRPLModelException::XRPLTransactionError(error.into()) + } +} + impl From for XRPLModelException { fn from(error: XRPLNFTokenCancelOfferException) -> Self { XRPLModelException::XRPLTransactionError(error.into()) diff --git a/src/models/ledger/objects/did.rs b/src/models/ledger/objects/did.rs new file mode 100644 index 00000000..81d47d3e --- /dev/null +++ b/src/models/ledger/objects/did.rs @@ -0,0 +1,137 @@ +use crate::models::ledger::objects::LedgerEntryType; +use crate::models::FlagCollection; +use crate::models::Model; +use crate::models::NoFlags; +use alloc::borrow::Cow; + +use serde::{Deserialize, Serialize}; + +use serde_with::skip_serializing_none; + +use super::{CommonFields, LedgerObject}; + +/// The `DID` object type holds references to, or data associated with, a single +/// Decentralized Identifier (DID). +/// +/// `` +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct DID<'a> { + /// The base fields for all ledger object models. + /// + /// See Ledger Object Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The account that controls the DID. + pub account: Cow<'a, str>, + /// The W3C standard DID document associated with the DID. + /// Limited to a maximum length of 256 bytes. + #[serde(rename = "DIDDocument")] + pub did_document: Option>, + /// The public attestations of identity credentials associated with the DID. + /// Limited to a maximum length of 256 bytes. + pub data: Option>, + /// The Universal Resource Identifier that points to the corresponding + /// DID document or the data associated with the DID. + /// Limited to a maximum length of 256 bytes. + #[serde(rename = "URI")] + pub uri: Option>, + /// A hint indicating which page of the owner directory links to this object. + pub owner_node: Cow<'a, str>, + /// The identifying hash of the transaction that most recently modified this object. + #[serde(rename = "PreviousTxnID")] + pub previous_txn_id: Cow<'a, str>, + /// The index of the ledger that contains the transaction that most recently + /// modified this object. + pub previous_txn_lgr_seq: u32, +} + +impl<'a> Model for DID<'a> {} + +impl<'a> LedgerObject for DID<'a> { + fn get_ledger_entry_type(&self) -> LedgerEntryType { + self.common_fields.get_ledger_entry_type() + } +} + +impl<'a> DID<'a> { + pub fn new( + index: Option>, + ledger_index: Option>, + account: Cow<'a, str>, + did_document: Option>, + data: Option>, + uri: Option>, + owner_node: Cow<'a, str>, + previous_txn_id: Cow<'a, str>, + previous_txn_lgr_seq: u32, + ) -> Self { + Self { + common_fields: CommonFields { + flags: FlagCollection::default(), + ledger_entry_type: LedgerEntryType::DID, + index, + ledger_index, + }, + account, + did_document, + data, + uri, + owner_node, + previous_txn_id, + previous_txn_lgr_seq, + } + } +} + +#[cfg(test)] +mod test_serde { + use super::*; + use alloc::borrow::Cow; + + #[test] + fn test_serialize() { + let did = DID::new( + Some(Cow::from( + "46813BE38B798B3752CA590D44E7FEADB17485649074403AD1761A2835CE91FF", + )), + None, + Cow::from("rpfqJrXg5uidNo2ZsRhRY6TiF1cvYmV9Fg"), + Some(Cow::from("646F63")), + Some(Cow::from("617474657374")), + Some(Cow::from("6469645F6578616D706C65")), + Cow::from("0"), + Cow::from("A4C15DA185E6092DF5954FF62A1446220C61A5F60F0D93B4B09F708778E41120"), + 4, + ); + let serialized = serde_json::to_string(&did).unwrap(); + let deserialized: DID = serde_json::from_str(&serialized).unwrap(); + assert_eq!(did, deserialized); + } + + #[test] + fn test_deserialize_from_json() { + let json = r#"{ + "Account": "rpfqJrXg5uidNo2ZsRhRY6TiF1cvYmV9Fg", + "DIDDocument": "646F63", + "Data": "617474657374", + "Flags": 0, + "LedgerEntryType": "DID", + "OwnerNode": "0", + "PreviousTxnID": "A4C15DA185E6092DF5954FF62A1446220C61A5F60F0D93B4B09F708778E41120", + "PreviousTxnLgrSeq": 4, + "URI": "6469645F6578616D706C65", + "index": "46813BE38B798B3752CA590D44E7FEADB17485649074403AD1761A2835CE91FF" + }"#; + + let did: DID = serde_json::from_str(json).unwrap(); + assert_eq!(did.account, "rpfqJrXg5uidNo2ZsRhRY6TiF1cvYmV9Fg"); + assert_eq!(did.did_document.as_deref(), Some("646F63")); + assert_eq!(did.data.as_deref(), Some("617474657374")); + assert_eq!(did.uri.as_deref(), Some("6469645F6578616D706C65")); + assert_eq!(did.owner_node, "0"); + assert_eq!(did.previous_txn_lgr_seq, 4); + } +} diff --git a/src/models/ledger/objects/mod.rs b/src/models/ledger/objects/mod.rs index 45fa941f..af7c7540 100644 --- a/src/models/ledger/objects/mod.rs +++ b/src/models/ledger/objects/mod.rs @@ -4,6 +4,7 @@ pub mod amm; pub mod bridge; pub mod check; pub mod deposit_preauth; +pub mod did; pub mod directory_node; pub mod escrow; pub mod fee_settings; @@ -26,6 +27,7 @@ use bridge::Bridge; use check::Check; use deposit_preauth::DepositPreauth; use derive_new::new; +use did::DID; use directory_node::DirectoryNode; use escrow::Escrow; use fee_settings::FeeSettings; @@ -57,6 +59,7 @@ pub enum LedgerEntryType { AMM = 0x0079, Bridge = 0x0069, Check = 0x0043, + DID = 0x0049, DepositPreauth = 0x0070, DirectoryNode = 0x0064, Escrow = 0x0075, @@ -81,6 +84,7 @@ pub enum LedgerEntry<'a> { AMM(AMM<'a>), Bridge(Bridge<'a>), Check(Check<'a>), + DID(DID<'a>), DepositPreauth(DepositPreauth<'a>), DirectoryNode(DirectoryNode<'a>), Escrow(Escrow<'a>), diff --git a/src/models/requests/account_objects.rs b/src/models/requests/account_objects.rs index 11060adf..b9d1f826 100644 --- a/src/models/requests/account_objects.rs +++ b/src/models/requests/account_objects.rs @@ -14,6 +14,9 @@ use super::{CommonFields, LedgerIndex, LookupByLedgerRequest, Marker, Request}; #[serde(rename_all = "snake_case")] pub enum AccountObjectType { Check, + #[serde(rename = "did")] + #[strum(serialize = "did")] + DID, DepositPreauth, Escrow, Offer, diff --git a/src/models/results/server_state.rs b/src/models/results/server_state.rs index a0211861..74ea5ee4 100644 --- a/src/models/results/server_state.rs +++ b/src/models/results/server_state.rs @@ -20,6 +20,8 @@ pub struct State<'a> { pub closed_ledger: Option>, /// Amount of time spent waiting for I/O operations, in milliseconds pub io_latency_ms: Option, + /// The network ID of the chain this server is connected to. + pub network_id: Option, /// Number of times server had over 250 transactions waiting to be processed pub jq_trans_overflow: Option>, /// Information about the last time the server closed a ledger diff --git a/src/models/transactions/did_delete.rs b/src/models/transactions/did_delete.rs new file mode 100644 index 00000000..6d9ff7a3 --- /dev/null +++ b/src/models/transactions/did_delete.rs @@ -0,0 +1,172 @@ +use alloc::borrow::Cow; +use alloc::vec::Vec; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::models::amount::XRPAmount; +use crate::models::{ + transactions::{Memo, Signer, Transaction, TransactionType}, + Model, +}; +use crate::models::{FlagCollection, NoFlags}; + +use super::{CommonFields, CommonTransactionBuilder}; + +/// Delete the DID (Decentralized Identifier) associated with the +/// sending account. +/// +/// See DIDDelete: +/// `` +#[skip_serializing_none] +#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct DIDDelete<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, +} + +impl<'a> Model for DIDDelete<'a> {} + +impl<'a> Transaction<'a, NoFlags> for DIDDelete<'a> { + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } + + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + self.common_fields.get_common_fields() + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + self.common_fields.get_mut_common_fields() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for DIDDelete<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +impl<'a> DIDDelete<'a> { + pub fn new( + account: Cow<'a, str>, + account_txn_id: Option>, + fee: Option>, + last_ledger_sequence: Option, + memos: Option>, + sequence: Option, + signers: Option>, + source_tag: Option, + ticket_sequence: Option, + ) -> Self { + Self { + common_fields: CommonFields::new( + account, + TransactionType::DIDDelete, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid() { + let tx = DIDDelete { + common_fields: CommonFields { + account: "rp4pqYgrTAtdPHuZd1ZQWxrzx45jxYcZex".into(), + transaction_type: TransactionType::DIDDelete, + ..Default::default() + }, + }; + assert!(tx.is_valid()); + } + + #[test] + fn test_serialize() { + let tx = DIDDelete { + common_fields: CommonFields { + account: "rp4pqYgrTAtdPHuZd1ZQWxrzx45jxYcZex".into(), + transaction_type: TransactionType::DIDDelete, + fee: Some("12".into()), + sequence: Some(391), + signing_pub_key: Some( + "0293A815C095DBA82FAC597A6BB9D338674DB93168156D84D18417AD509FFF5904".into(), + ), + ..Default::default() + }, + }; + + let expected_json = r#"{"Account":"rp4pqYgrTAtdPHuZd1ZQWxrzx45jxYcZex","TransactionType":"DIDDelete","Fee":"12","Flags":0,"Sequence":391,"SigningPubKey":"0293A815C095DBA82FAC597A6BB9D338674DB93168156D84D18417AD509FFF5904"}"#; + + let serialized = serde_json::to_string(&tx).unwrap(); + let expected_value = serde_json::to_value(expected_json).unwrap(); + let serialized_value = serde_json::to_value(&serialized).unwrap(); + assert_eq!(serialized_value, expected_value); + + let deserialized: DIDDelete = serde_json::from_str(expected_json).unwrap(); + assert_eq!(tx, deserialized); + } + + #[test] + fn test_builder_pattern() { + let tx = DIDDelete { + common_fields: CommonFields { + account: "rp4pqYgrTAtdPHuZd1ZQWxrzx45jxYcZex".into(), + transaction_type: TransactionType::DIDDelete, + ..Default::default() + }, + } + .with_fee("12".into()) + .with_sequence(391) + .with_last_ledger_sequence(7108682); + + assert_eq!(tx.common_fields.fee.as_ref().unwrap().0, "12"); + assert_eq!(tx.common_fields.sequence, Some(391)); + assert_eq!(tx.common_fields.last_ledger_sequence, Some(7108682)); + } + + #[test] + fn test_default() { + let tx = DIDDelete { + common_fields: CommonFields { + account: "rp4pqYgrTAtdPHuZd1ZQWxrzx45jxYcZex".into(), + transaction_type: TransactionType::DIDDelete, + ..Default::default() + }, + }; + + assert_eq!( + tx.common_fields.account, + "rp4pqYgrTAtdPHuZd1ZQWxrzx45jxYcZex" + ); + assert_eq!( + tx.common_fields.transaction_type, + TransactionType::DIDDelete + ); + assert!(tx.common_fields.fee.is_none()); + assert!(tx.common_fields.sequence.is_none()); + } +} diff --git a/src/models/transactions/did_set.rs b/src/models/transactions/did_set.rs new file mode 100644 index 00000000..3a49c108 --- /dev/null +++ b/src/models/transactions/did_set.rs @@ -0,0 +1,468 @@ +use alloc::borrow::Cow; +use alloc::vec::Vec; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +use crate::models::amount::XRPAmount; +use crate::models::{ + transactions::{Memo, Signer, Transaction, TransactionType}, + Model, XRPLModelResult, +}; +use crate::models::{FlagCollection, NoFlags}; + +use super::{ + exceptions::{XRPLDIDSetException, XRPLTransactionException}, + CommonFields, CommonTransactionBuilder, +}; + +/// Maximum length in hex characters for DID fields (Data, DIDDocument, URI). +/// Each field is limited to 256 bytes, which is 256 hex characters in +/// the client library convention (matching xrpl-py). +pub const MAX_DID_FIELD_LENGTH: usize = 256; + +/// Create or update a DID (Decentralized Identifier) associated with +/// the sending account. +/// +/// See DIDSet: +/// `` +#[skip_serializing_none] +#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "PascalCase")] +pub struct DIDSet<'a> { + /// The base fields for all transaction models. + /// + /// See Transaction Common Fields: + /// `` + #[serde(flatten)] + pub common_fields: CommonFields<'a, NoFlags>, + /// The public attestations of identity credentials associated with the DID. + pub data: Option>, + /// The DID document associated with the DID. + #[serde(rename = "DIDDocument")] + pub did_document: Option>, + /// The Universal Resource Identifier associated with the DID. + #[serde(rename = "URI")] + pub uri: Option>, +} + +impl<'a> Model for DIDSet<'a> { + fn get_errors(&self) -> XRPLModelResult<()> { + self.get_did_field_errors() + } +} + +impl<'a> Transaction<'a, NoFlags> for DIDSet<'a> { + fn get_transaction_type(&self) -> &TransactionType { + self.common_fields.get_transaction_type() + } + + fn get_common_fields(&self) -> &CommonFields<'_, NoFlags> { + self.common_fields.get_common_fields() + } + + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + self.common_fields.get_mut_common_fields() + } +} + +impl<'a> CommonTransactionBuilder<'a, NoFlags> for DIDSet<'a> { + fn get_mut_common_fields(&mut self) -> &mut CommonFields<'a, NoFlags> { + &mut self.common_fields + } + + fn into_self(self) -> Self { + self + } +} + +/// Returns true if the string is valid hexadecimal (only chars 0-9, a-f, A-F). +/// An empty string is considered valid hex. +fn is_hex(s: &str) -> bool { + s.chars().all(|c| c.is_ascii_hexdigit()) +} + +impl<'a> DIDSet<'a> { + pub fn new( + account: Cow<'a, str>, + account_txn_id: Option>, + fee: Option>, + last_ledger_sequence: Option, + memos: Option>, + sequence: Option, + signers: Option>, + source_tag: Option, + ticket_sequence: Option, + data: Option>, + did_document: Option>, + uri: Option>, + ) -> Self { + Self { + common_fields: CommonFields::new( + account, + TransactionType::DIDSet, + account_txn_id, + fee, + Some(FlagCollection::default()), + last_ledger_sequence, + memos, + None, + sequence, + signers, + None, + source_tag, + ticket_sequence, + None, + ), + data, + did_document, + uri, + } + } + + /// Validate the DID-specific fields. + fn get_did_field_errors(&self) -> XRPLModelResult<()> { + // At least one of data, did_document, uri must be provided + if self.data.is_none() && self.did_document.is_none() && self.uri.is_none() { + return Err(XRPLTransactionException::from( + XRPLDIDSetException::MustHaveAtLeastOneField, + ) + .into()); + } + + // If all provided fields are empty strings, that's invalid + let all_empty = self.data.as_deref().is_none_or(|s| s.is_empty()) + && self.did_document.as_deref().is_none_or(|s| s.is_empty()) + && self.uri.as_deref().is_none_or(|s| s.is_empty()); + + // Only check "all empty" if at least one field IS provided (we already checked all-None above) + if all_empty { + return Err(XRPLTransactionException::from( + XRPLDIDSetException::AtLeastOneFieldMustBeNonEmpty, + ) + .into()); + } + + // Validate each field individually + self.validate_did_field("data", self.data.as_deref())?; + self.validate_did_field("did_document", self.did_document.as_deref())?; + self.validate_did_field("uri", self.uri.as_deref())?; + + Ok(()) + } + + /// Validate a single DID field for hex format and max length. + fn validate_did_field(&self, field_name: &str, value: Option<&str>) -> XRPLModelResult<()> { + if let Some(val) = value { + if val.is_empty() { + // Empty string is valid (used to delete a field) + return Ok(()); + } + + let valid_hex = is_hex(val); + let valid_length = val.len() <= MAX_DID_FIELD_LENGTH; + + if !valid_hex && !valid_length { + return Err(XRPLTransactionException::from( + XRPLDIDSetException::InvalidFieldHexAndTooLong { + field: field_name.into(), + found_length: val.len(), + }, + ) + .into()); + } + + if !valid_hex { + return Err( + XRPLTransactionException::from(XRPLDIDSetException::InvalidFieldHex { + field: field_name.into(), + }) + .into(), + ); + } + + if !valid_length { + return Err( + XRPLTransactionException::from(XRPLDIDSetException::FieldTooLong { + field: field_name.into(), + max: MAX_DID_FIELD_LENGTH, + found: val.len(), + }) + .into(), + ); + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ACCOUNT: &str = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ"; + const VALID_FIELD: &str = "1234567890abcdefABCDEF"; + const TOO_LONG_FIELD: &str = concat!( + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "A" + ); // 257 chars + const BAD_HEX_FIELD: &str = "random_non_hex_content"; + + #[test] + fn test_valid_all_fields() { + let tx = DIDSet { + common_fields: CommonFields { + account: ACCOUNT.into(), + transaction_type: TransactionType::DIDSet, + ..Default::default() + }, + data: Some(VALID_FIELD.into()), + did_document: Some(VALID_FIELD.into()), + uri: Some(VALID_FIELD.into()), + }; + assert!(tx.is_valid()); + } + + #[test] + fn test_valid_only_data() { + let tx = DIDSet { + common_fields: CommonFields { + account: ACCOUNT.into(), + transaction_type: TransactionType::DIDSet, + ..Default::default() + }, + data: Some(VALID_FIELD.into()), + did_document: None, + uri: None, + }; + assert!(tx.is_valid()); + } + + #[test] + fn test_valid_only_did_document() { + let tx = DIDSet { + common_fields: CommonFields { + account: ACCOUNT.into(), + transaction_type: TransactionType::DIDSet, + ..Default::default() + }, + data: None, + did_document: Some(VALID_FIELD.into()), + uri: None, + }; + assert!(tx.is_valid()); + } + + #[test] + fn test_valid_only_uri() { + let tx = DIDSet { + common_fields: CommonFields { + account: ACCOUNT.into(), + transaction_type: TransactionType::DIDSet, + ..Default::default() + }, + data: None, + did_document: None, + uri: Some(VALID_FIELD.into()), + }; + assert!(tx.is_valid()); + } + + #[test] + fn test_empty_no_fields() { + let tx = DIDSet { + common_fields: CommonFields { + account: ACCOUNT.into(), + transaction_type: TransactionType::DIDSet, + ..Default::default() + }, + data: None, + did_document: None, + uri: None, + }; + assert!(!tx.is_valid()); + let err = tx.validate().unwrap_err(); + assert!(err.to_string().contains("Must have at least one")); + } + + #[test] + fn test_all_empty_strings() { + let tx = DIDSet { + common_fields: CommonFields { + account: ACCOUNT.into(), + transaction_type: TransactionType::DIDSet, + ..Default::default() + }, + data: Some("".into()), + did_document: Some("".into()), + uri: Some("".into()), + }; + assert!(!tx.is_valid()); + let err = tx.validate().unwrap_err(); + assert!(err + .to_string() + .contains("must have a length greater than zero")); + } + + #[test] + fn test_single_empty_data_field_is_valid() { + // An empty string for one field is valid (used to delete that field) + // as long as at least one other field is non-empty or not all are empty + let tx = DIDSet { + common_fields: CommonFields { + account: ACCOUNT.into(), + transaction_type: TransactionType::DIDSet, + ..Default::default() + }, + data: Some("".into()), + did_document: Some(VALID_FIELD.into()), + uri: None, + }; + assert!(tx.is_valid()); + } + + #[test] + fn test_too_long() { + let tx = DIDSet { + common_fields: CommonFields { + account: ACCOUNT.into(), + transaction_type: TransactionType::DIDSet, + ..Default::default() + }, + data: None, + did_document: Some(TOO_LONG_FIELD.into()), + uri: None, + }; + assert!(!tx.is_valid()); + let err = tx.validate().unwrap_err(); + assert!(err.to_string().contains("256")); + } + + #[test] + fn test_not_hex() { + let tx = DIDSet { + common_fields: CommonFields { + account: ACCOUNT.into(), + transaction_type: TransactionType::DIDSet, + ..Default::default() + }, + data: Some(BAD_HEX_FIELD.into()), + did_document: None, + uri: None, + }; + assert!(!tx.is_valid()); + let err = tx.validate().unwrap_err(); + assert!(err.to_string().contains("hex")); + } + + #[test] + fn test_too_long_and_not_hex() { + // 257 non-hex chars + let bad_field: alloc::string::String = "q".repeat(257); + let tx = DIDSet { + common_fields: CommonFields { + account: ACCOUNT.into(), + transaction_type: TransactionType::DIDSet, + ..Default::default() + }, + data: None, + did_document: None, + uri: Some(bad_field.into()), + }; + assert!(!tx.is_valid()); + let err = tx.validate().unwrap_err(); + assert!(err.to_string().contains("hex")); + assert!(err.to_string().contains("256")); + } + + #[test] + fn test_serialize() { + let tx = DIDSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::DIDSet, + fee: Some("10".into()), + sequence: Some(391), + signing_pub_key: Some( + "0330E7FC9D56BB25D6893BA3F317AE5BCF33B3291BD63DB32654A313222F7FD020".into(), + ), + ..Default::default() + }, + data: Some("617474657374".into()), + did_document: Some("646F63".into()), + uri: Some("6469645F6578616D706C65".into()), + }; + + let expected_json = r#"{"Account":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","TransactionType":"DIDSet","Fee":"10","Flags":0,"Sequence":391,"SigningPubKey":"0330E7FC9D56BB25D6893BA3F317AE5BCF33B3291BD63DB32654A313222F7FD020","Data":"617474657374","DIDDocument":"646F63","URI":"6469645F6578616D706C65"}"#; + + let serialized = serde_json::to_string(&tx).unwrap(); + let expected_value = serde_json::to_value(expected_json).unwrap(); + let serialized_value = serde_json::to_value(&serialized).unwrap(); + assert_eq!(serialized_value, expected_value); + + let deserialized: DIDSet = serde_json::from_str(expected_json).unwrap(); + assert_eq!(tx, deserialized); + } + + #[test] + fn test_builder_pattern() { + let tx = DIDSet { + common_fields: CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: TransactionType::DIDSet, + ..Default::default() + }, + data: Some("617474657374".into()), + did_document: Some("646F63".into()), + uri: Some("6469645F6578616D706C65".into()), + } + .with_fee("10".into()) + .with_sequence(391) + .with_last_ledger_sequence(7108682); + + assert_eq!(tx.data.as_deref(), Some("617474657374")); + assert_eq!(tx.did_document.as_deref(), Some("646F63")); + assert_eq!(tx.uri.as_deref(), Some("6469645F6578616D706C65")); + assert_eq!(tx.common_fields.fee.as_ref().unwrap().0, "10"); + assert_eq!(tx.common_fields.sequence, Some(391)); + assert_eq!(tx.common_fields.last_ledger_sequence, Some(7108682)); + } + + #[test] + fn test_default() { + let tx = DIDSet { + common_fields: CommonFields { + account: ACCOUNT.into(), + transaction_type: TransactionType::DIDSet, + ..Default::default() + }, + data: Some(VALID_FIELD.into()), + ..Default::default() + }; + + assert_eq!(tx.common_fields.account, ACCOUNT); + assert_eq!(tx.common_fields.transaction_type, TransactionType::DIDSet); + assert_eq!(tx.data.as_deref(), Some(VALID_FIELD)); + assert!(tx.did_document.is_none()); + assert!(tx.uri.is_none()); + } + + #[test] + fn test_max_length_exactly_256() { + let max_field: alloc::string::String = "A".repeat(256); + let tx = DIDSet { + common_fields: CommonFields { + account: ACCOUNT.into(), + transaction_type: TransactionType::DIDSet, + ..Default::default() + }, + data: Some(max_field.into()), + did_document: None, + uri: None, + }; + assert!(tx.is_valid()); + } +} diff --git a/src/models/transactions/exceptions.rs b/src/models/transactions/exceptions.rs index d5535c60..1499b3b1 100644 --- a/src/models/transactions/exceptions.rs +++ b/src/models/transactions/exceptions.rs @@ -10,6 +10,8 @@ pub enum XRPLTransactionException { #[error("{0}")] XRPLAccountSetError(#[from] XRPLAccountSetException), #[error("{0}")] + XRPLDIDSetError(#[from] XRPLDIDSetException), + #[error("{0}")] XRPLNFTokenCancelOfferError(#[from] XRPLNFTokenCancelOfferException), #[error("{0}")] XRPLNFTokenCreateOfferError(#[from] XRPLNFTokenCreateOfferException), @@ -214,3 +216,24 @@ pub enum XRPLAMMCreateException { #[cfg(feature = "std")] impl alloc::error::Error for XRPLAMMCreateException {} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum XRPLDIDSetException { + #[error("Must have at least one of `data`, `did_document`, and `uri`")] + MustHaveAtLeastOneField, + #[error("At least one of the fields `data`, `did_document`, and `uri` must have a length greater than zero")] + AtLeastOneFieldMustBeNonEmpty, + #[error("The field `{field:?}` must be hex-encoded")] + InvalidFieldHex { field: String }, + #[error("The field `{field:?}` must be <= {max} characters (found {found})")] + FieldTooLong { + field: String, + max: usize, + found: usize, + }, + #[error("The field `{field:?}` must be hex-encoded and must be <= 256 characters (found {found_length})")] + InvalidFieldHexAndTooLong { field: String, found_length: usize }, +} + +#[cfg(feature = "std")] +impl alloc::error::Error for XRPLDIDSetException {} diff --git a/src/models/transactions/mod.rs b/src/models/transactions/mod.rs index 167d56c8..e11d5aee 100644 --- a/src/models/transactions/mod.rs +++ b/src/models/transactions/mod.rs @@ -10,6 +10,8 @@ pub mod check_cancel; pub mod check_cash; pub mod check_create; pub mod deposit_preauth; +pub mod did_delete; +pub mod did_set; pub mod escrow_cancel; pub mod escrow_create; pub mod escrow_finish; @@ -76,6 +78,8 @@ pub enum TransactionType { CheckCancel, CheckCash, CheckCreate, + DIDDelete, + DIDSet, DepositPreauth, EscrowCancel, EscrowCreate, @@ -173,6 +177,7 @@ where /// The network ID of the chain this transaction is intended for. /// MUST BE OMITTED for Mainnet and some test networks. /// REQUIRED on chains whose network ID is 1025 or higher. + #[serde(rename = "NetworkID")] pub network_id: Option, /// The sequence number of the account sending the transaction. /// A transaction is only valid if the Sequence number is exactly diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 14093264..575fdf6a 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -28,7 +28,7 @@ const GENESIS_SEED: &str = "snoPBrXtMeMyMHUVTgbuqAfg1SUTb"; /// HTTP JSON-RPC endpoint for local Docker standalone mode. #[cfg(feature = "std")] -const STANDALONE_URL: &str = "http://localhost:5005"; +const STANDALONE_URL: &str = "http://127.0.0.1:5005"; #[cfg(all(feature = "websocket", not(feature = "std")))] pub async fn open_websocket( @@ -113,11 +113,19 @@ pub async fn generate_funded_wallet() -> Wallet { ); let client = get_client().await; - sign_and_submit(&mut payment, client, &genesis, true, true) + let result = sign_and_submit(&mut payment, client, &genesis, true, true) .await .expect("generate_funded_wallet: funding payment failed"); + // Advance the ledger to confirm the funding payment ledger_accept().await; + + assert_eq!( + result.engine_result, "tesSUCCESS", + "generate_funded_wallet: funding payment engine_result was {}", + result.engine_result + ); + new_wallet } diff --git a/tests/transactions/did_delete.rs b/tests/transactions/did_delete.rs new file mode 100644 index 00000000..ec26683a --- /dev/null +++ b/tests/transactions/did_delete.rs @@ -0,0 +1,147 @@ +// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/didSet.test.ts +// (DIDDelete is tested alongside DIDSet in other client libraries) +// +// Scenarios: +// - base: create a DID then delete it, verify the DID is removed +// - delete_nonexistent: attempt to delete a DID that doesn't exist, expect tecNO_ENTRY + +use crate::common::{ + generate_funded_wallet, get_client, ledger_accept, test_transaction, with_blockchain_lock, +}; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::asynch::transaction::sign_and_submit; +use xrpl::models::requests::account_objects::{AccountObjectType, AccountObjects}; +use xrpl::models::results; +use xrpl::models::transactions::did_delete::DIDDelete; +use xrpl::models::transactions::did_set::DIDSet; + +const DATA_HEX: &str = "617474657374"; +const DID_DOCUMENT_HEX: &str = "646F63"; +const URI_HEX: &str = "6469645F6578616D706C65"; + +#[tokio::test] +async fn test_did_delete_base() { + with_blockchain_lock(|| async { + let wallet = generate_funded_wallet().await; + let client = get_client().await; + + // Step 1: Create a DID + let mut create_tx = DIDSet::new( + wallet.classic_address.clone().into(), + None, + None, + None, + None, + None, + None, + None, + None, + Some(DATA_HEX.into()), + Some(DID_DOCUMENT_HEX.into()), + Some(URI_HEX.into()), + ); + test_transaction(&mut create_tx, &wallet).await; + + // Verify DID exists + let ao_response = client + .request( + AccountObjects::new( + None, + wallet.classic_address.clone().into(), + None, + None, + Some(AccountObjectType::DID), + None, + None, + None, + ) + .into(), + ) + .await + .expect("account_objects request failed"); + + let objects_result: results::account_objects::AccountObjects<'_> = ao_response + .try_into() + .expect("failed to parse account_objects result"); + assert_eq!( + objects_result.account_objects.len(), + 1, + "DID should exist after DIDSet" + ); + + // Step 2: Delete the DID + let mut delete_tx = DIDDelete::new( + wallet.classic_address.clone().into(), + None, + None, + None, + None, + None, + None, + None, + None, + ); + test_transaction(&mut delete_tx, &wallet).await; + + // Step 3: Verify DID is gone + let ao_response2 = client + .request( + AccountObjects::new( + None, + wallet.classic_address.clone().into(), + None, + None, + Some(AccountObjectType::DID), + None, + None, + None, + ) + .into(), + ) + .await + .expect("account_objects request failed"); + + let objects_result2: results::account_objects::AccountObjects<'_> = ao_response2 + .try_into() + .expect("failed to parse account_objects result"); + assert!( + objects_result2.account_objects.is_empty(), + "DID should be removed after DIDDelete" + ); + }) + .await; +} + +#[tokio::test] +async fn test_did_delete_nonexistent() { + with_blockchain_lock(|| async { + let wallet = generate_funded_wallet().await; + let client = get_client().await; + + // Attempt to delete a DID that doesn't exist + let mut tx = DIDDelete::new( + wallet.classic_address.clone().into(), + None, + None, + None, + None, + None, + None, + None, + None, + ); + + let result = sign_and_submit(&mut tx, client, &wallet, true, true) + .await + .expect("sign_and_submit should not error at network level"); + + assert!( + result.engine_result.contains("tecNO_ENTRY"), + "Expected tecNO_ENTRY when deleting non-existent DID, got: {}", + result.engine_result + ); + + ledger_accept().await; + }) + .await; +} diff --git a/tests/transactions/did_set.rs b/tests/transactions/did_set.rs new file mode 100644 index 00000000..348e51c7 --- /dev/null +++ b/tests/transactions/did_set.rs @@ -0,0 +1,200 @@ +// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/didSet.test.ts +// +// Scenarios: +// - base: create a DID with all three fields (data, did_document, uri) +// - update: modify an existing DID by changing URI and clearing DIDDocument +// - single_field: create a DID with only the data field +// - empty_field_deletion: set a field to empty string to delete it from an existing DID + +use crate::common::{generate_funded_wallet, get_client, test_transaction, with_blockchain_lock}; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::requests::account_objects::{AccountObjectType, AccountObjects}; +use xrpl::models::results; +use xrpl::models::transactions::did_set::DIDSet; + +/// Hex-encoded values matching the xrpl.js integration tests: +/// "617474657374" = "attest" +/// "646F63" = "doc" +/// "6469645F6578616D706C65" = "did_example" +const DATA_HEX: &str = "617474657374"; +const DID_DOCUMENT_HEX: &str = "646F63"; +const URI_HEX: &str = "6469645F6578616D706C65"; + +#[tokio::test] +async fn test_did_set_all_fields() { + with_blockchain_lock(|| async { + let wallet = generate_funded_wallet().await; + + let mut tx = DIDSet::new( + wallet.classic_address.clone().into(), + None, // account_txn_id + None, // fee + None, // last_ledger_sequence + None, // memos + None, // sequence + None, // signers + None, // source_tag + None, // ticket_sequence + Some(DATA_HEX.into()), + Some(DID_DOCUMENT_HEX.into()), + Some(URI_HEX.into()), + ); + + test_transaction(&mut tx, &wallet).await; + + // Verify the DID was created by querying account_objects + let client = get_client().await; + let ao_response = client + .request( + AccountObjects::new( + None, + wallet.classic_address.clone().into(), + None, + None, + Some(AccountObjectType::DID), + None, + None, + None, + ) + .into(), + ) + .await + .expect("account_objects request failed"); + + let objects_result: results::account_objects::AccountObjects<'_> = ao_response + .try_into() + .expect("failed to parse account_objects result"); + + assert_eq!( + objects_result.account_objects.len(), + 1, + "Should be exactly one DID on the ledger after DIDSet" + ); + + let did_obj = &objects_result.account_objects[0]; + assert_eq!(did_obj["Data"].as_str().unwrap(), DATA_HEX); + assert_eq!(did_obj["DIDDocument"].as_str().unwrap(), DID_DOCUMENT_HEX); + assert_eq!(did_obj["URI"].as_str().unwrap(), URI_HEX); + }) + .await; +} + +#[tokio::test] +async fn test_did_set_update() { + with_blockchain_lock(|| async { + let wallet = generate_funded_wallet().await; + + // Step 1: Create DID with all fields + let mut create_tx = DIDSet::new( + wallet.classic_address.clone().into(), + None, + None, + None, + None, + None, + None, + None, + None, + Some(DATA_HEX.into()), + Some(DID_DOCUMENT_HEX.into()), + Some(URI_HEX.into()), + ); + test_transaction(&mut create_tx, &wallet).await; + + // Step 2: Update DID — change URI, clear DIDDocument, leave Data unchanged + let new_uri = "ABCD"; + let mut update_tx = DIDSet::new( + wallet.classic_address.clone().into(), + None, + None, + None, + None, + None, + None, + None, + None, + None, // data: omit to leave unchanged + Some("".into()), // did_document: empty string to delete + Some(new_uri.into()), // uri: update value + ); + test_transaction(&mut update_tx, &wallet).await; + + // Step 3: Verify the update + let client = get_client().await; + let ao_response = client + .request( + AccountObjects::new( + None, + wallet.classic_address.clone().into(), + None, + None, + Some(AccountObjectType::DID), + None, + None, + None, + ) + .into(), + ) + .await + .expect("account_objects request failed"); + + let objects_result: results::account_objects::AccountObjects<'_> = ao_response + .try_into() + .expect("failed to parse account_objects result"); + + assert_eq!(objects_result.account_objects.len(), 1); + let did_obj = &objects_result.account_objects[0]; + // Data should be unchanged + assert_eq!(did_obj["Data"].as_str().unwrap(), DATA_HEX); + // DIDDocument should be removed (field absent from ledger object) + assert!( + did_obj.get("DIDDocument").is_none() + || did_obj["DIDDocument"] + .as_str() + .map_or(true, |s| s.is_empty()), + "DIDDocument should be cleared" + ); + // URI should be updated + assert_eq!(did_obj["URI"].as_str().unwrap(), new_uri); + }) + .await; +} + +#[tokio::test] +async fn test_did_set_empty_did_rejected() { + // DIDSet with all empty string fields should fail client-side validation. + // The sign_and_submit function also runs model validation, so this test + // verifies that invalid DIDSet transactions are rejected before submission. + let tx = DIDSet { + common_fields: xrpl::models::transactions::CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: xrpl::models::transactions::TransactionType::DIDSet, + ..Default::default() + }, + data: Some("".into()), + did_document: Some("".into()), + uri: Some("".into()), + }; + + assert!( + !xrpl::models::Model::is_valid(&tx), + "DIDSet with all empty fields should fail client-side validation" + ); + + // Also verify that a DIDSet with all None fields is rejected + let tx_none = DIDSet { + common_fields: xrpl::models::transactions::CommonFields { + account: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + transaction_type: xrpl::models::transactions::TransactionType::DIDSet, + ..Default::default() + }, + data: None, + did_document: None, + uri: None, + }; + + assert!( + !xrpl::models::Model::is_valid(&tx_none), + "DIDSet with no fields should fail client-side validation" + ); +} diff --git a/tests/transactions/mod.rs b/tests/transactions/mod.rs index c0e13b8d..4872debf 100644 --- a/tests/transactions/mod.rs +++ b/tests/transactions/mod.rs @@ -10,6 +10,8 @@ pub mod check_cancel; pub mod check_cash; pub mod check_create; pub mod deposit_preauth; +pub mod did_delete; +pub mod did_set; pub mod escrow_cancel; pub mod escrow_create; pub mod escrow_finish; From 59dfbb8430923d82162fb203a7dcdadabb357318 Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Fri, 10 Apr 2026 15:45:53 -0700 Subject: [PATCH 2/4] fix compiler errors; Update the length limit of data field to 512 hex characters --- src/models/transactions/did_set.rs | 35 ++++++++++++--------------- src/models/transactions/exceptions.rs | 2 +- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/models/transactions/did_set.rs b/src/models/transactions/did_set.rs index 3a49c108..0eb82903 100644 --- a/src/models/transactions/did_set.rs +++ b/src/models/transactions/did_set.rs @@ -16,9 +16,10 @@ use super::{ }; /// Maximum length in hex characters for DID fields (Data, DIDDocument, URI). -/// Each field is limited to 256 bytes, which is 256 hex characters in -/// the client library convention (matching xrpl-py). -pub const MAX_DID_FIELD_LENGTH: usize = 256; +/// Each field is limited to 256 bytes on the ledger. Since values are +/// hex-encoded in JSON (2 hex characters per byte), the limit is 512 +/// hex characters. +pub const MAX_DID_FIELD_LENGTH: usize = 512; /// Create or update a DID (Decentralized Identifier) associated with /// the sending account. @@ -203,12 +204,16 @@ mod tests { const ACCOUNT: &str = "r9LqNeG6qHxjeUocjvVki2XR35weJ9mZgQ"; const VALID_FIELD: &str = "1234567890abcdefABCDEF"; const TOO_LONG_FIELD: &str = concat!( + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "A" - ); // 257 chars + ); // 513 hex chars (> 512 limit) const BAD_HEX_FIELD: &str = "random_non_hex_content"; #[test] @@ -284,8 +289,7 @@ mod tests { uri: None, }; assert!(!tx.is_valid()); - let err = tx.validate().unwrap_err(); - assert!(err.to_string().contains("Must have at least one")); + assert!(tx.get_errors().is_err()); } #[test] @@ -301,10 +305,7 @@ mod tests { uri: Some("".into()), }; assert!(!tx.is_valid()); - let err = tx.validate().unwrap_err(); - assert!(err - .to_string() - .contains("must have a length greater than zero")); + assert!(tx.get_errors().is_err()); } #[test] @@ -337,8 +338,7 @@ mod tests { uri: None, }; assert!(!tx.is_valid()); - let err = tx.validate().unwrap_err(); - assert!(err.to_string().contains("256")); + assert!(tx.get_errors().is_err()); } #[test] @@ -354,8 +354,7 @@ mod tests { uri: None, }; assert!(!tx.is_valid()); - let err = tx.validate().unwrap_err(); - assert!(err.to_string().contains("hex")); + assert!(tx.get_errors().is_err()); } #[test] @@ -373,9 +372,7 @@ mod tests { uri: Some(bad_field.into()), }; assert!(!tx.is_valid()); - let err = tx.validate().unwrap_err(); - assert!(err.to_string().contains("hex")); - assert!(err.to_string().contains("256")); + assert!(tx.get_errors().is_err()); } #[test] @@ -451,8 +448,8 @@ mod tests { } #[test] - fn test_max_length_exactly_256() { - let max_field: alloc::string::String = "A".repeat(256); + fn test_max_length_exactly_512() { + let max_field: alloc::string::String = "A".repeat(512); let tx = DIDSet { common_fields: CommonFields { account: ACCOUNT.into(), diff --git a/src/models/transactions/exceptions.rs b/src/models/transactions/exceptions.rs index 1499b3b1..ef667b31 100644 --- a/src/models/transactions/exceptions.rs +++ b/src/models/transactions/exceptions.rs @@ -231,7 +231,7 @@ pub enum XRPLDIDSetException { max: usize, found: usize, }, - #[error("The field `{field:?}` must be hex-encoded and must be <= 256 characters (found {found_length})")] + #[error("The field `{field:?}` must be hex-encoded and must be <= 512 characters (found {found_length})")] InvalidFieldHexAndTooLong { field: String, found_length: usize }, } From fa4b3525de0dada216923d4b61ab752f99fda7fc Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S Date: Fri, 10 Apr 2026 15:47:21 -0700 Subject: [PATCH 3/4] fix: Update tests to validate 513 hex characters (invalid input) --- src/models/transactions/did_set.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/models/transactions/did_set.rs b/src/models/transactions/did_set.rs index 0eb82903..de2f6c16 100644 --- a/src/models/transactions/did_set.rs +++ b/src/models/transactions/did_set.rs @@ -359,8 +359,8 @@ mod tests { #[test] fn test_too_long_and_not_hex() { - // 257 non-hex chars - let bad_field: alloc::string::String = "q".repeat(257); + // 513 non-hex chars + let bad_field: alloc::string::String = "q".repeat(513); let tx = DIDSet { common_fields: CommonFields { account: ACCOUNT.into(), From 0a3121b660a2107c542cdc0c29e2325c8509ec53 Mon Sep 17 00:00:00 2001 From: e-desouza Date: Mon, 20 Apr 2026 04:59:48 +0000 Subject: [PATCH 4/4] ci: pin rippled Docker image and harden health check wait --- .github/workflows/integration_test.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 12e3df17..cd26abf8 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -11,7 +11,8 @@ on: name: Integration Test env: - RIPPLED_DOCKER_IMAGE: rippleci/rippled:develop + # Pin to known-good digest; rippleci/rippled:develop broke after 2026-04-01 + RIPPLED_DOCKER_IMAGE: rippleci/rippled:develop@sha256:328175bf14b7b83db9e5e6b50c7458bf828b02b2855453efc038233094aa8d85 jobs: integration_test: @@ -41,10 +42,22 @@ jobs: - name: Wait for rippled to be healthy run: | - until docker inspect --format='{{.State.Health.Status}}' rippled-service | grep -q healthy; do - echo "Waiting for rippled to be ready..." + for i in $(seq 1 30); do + if ! docker ps -q -f name=rippled-service | grep -q .; then + echo "Container exited unexpectedly" + docker logs rippled-service 2>&1 || true + exit 1 + fi + STATUS=$(docker inspect --format='{{.State.Health.Status}}' rippled-service 2>/dev/null || echo "unknown") + echo "Attempt $i/30: $STATUS" + if [ "$STATUS" = "healthy" ]; then + exit 0 + fi sleep 2 done + echo "Timed out waiting for rippled" + docker logs rippled-service 2>&1 || true + exit 1 - uses: dtolnay/rust-toolchain@stable