Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@
**/.DS_Store

rustc-ice*

# Local working files
.local/
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [[v1.1.0]]

- `DepositPreauth` ledger object: `authorize` field changed from `Cow<'a, str>` to `Option<Cow<'a, str>>` to support XLS-70 credential-based preauthorization. The `new()` constructor is unchanged (still accepts non-optional `authorize`), but direct struct construction must wrap the value in `Some(...)`.
- `credential_ids` field on `AccountDelete`, `Payment`, `EscrowFinish`, `PaymentChannelClaim`, and `credentials` on `DepositAuthorized` request changed from `Option<Cow<'a, [Cow<'a, str>]>>` to `Option<Vec<Cow<'a, str>>>` for reliable serde round-trip.

### Added

- Implemented full deserialization from hex binary back to JSON, update `definitions.json` to `xrpl.js` latest, added all codec test fixtures from xrpl.js and implemented tests for all of them.
- Added integration tests for all transaction types, refactored to separate files.
- Added initial XLS-70 Credentials model support (`CredentialCreate`, `CredentialAccept`, `CredentialDelete`, `Credential` ledger object, and credential-based `DepositPreauth` fields).

### Fixed

Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ anyhow = { version = "1.0.91", default-features = false }
once_cell = "1.20.3"
reqwest = { version = "0.12.7", features = ["json"] }
serde_json = "1.0"
proptest = "1.5"

[[bench]]
name = "benchmarks"
Expand Down
19 changes: 19 additions & 0 deletions src/models/credential_authorization.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
use alloc::borrow::Cow;
use derive_new::new;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;

#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, new)]
#[serde(rename_all = "PascalCase")]
pub struct CredentialAuthorizationFields<'a> {
pub issuer: Cow<'a, str>,
pub credential_type: Cow<'a, str>,
}

#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, new)]
#[serde(rename_all = "PascalCase")]
pub struct CredentialAuthorization<'a> {
pub credential: CredentialAuthorizationFields<'a>,
}
249 changes: 249 additions & 0 deletions src/models/ledger/objects/credential.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
use crate::models::ledger::objects::LedgerEntryType;
use crate::models::FlagCollection;
use crate::models::Model;
use alloc::borrow::Cow;

use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};

use serde_with::skip_serializing_none;
use strum_macros::{AsRefStr, Display, EnumIter};

use super::{CommonFields, LedgerObject};

#[derive(
Debug, Eq, PartialEq, Clone, Serialize_repr, Deserialize_repr, Display, AsRefStr, EnumIter,
)]
#[repr(u32)]
pub enum CredentialFlag {
/// Credential has been accepted by the subject.
LsfAccepted = 0x00010000,
}

/// A `Credential` object is an on-ledger representation of a credential.
///
/// `<https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0070-credentials>`
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
#[serde(rename_all = "PascalCase")]
pub struct Credential<'a> {
/// The base fields for all ledger object models.
#[serde(flatten)]
pub common_fields: CommonFields<'a, CredentialFlag>,
/// The account the credential is for.
pub subject: Cow<'a, str>,
/// The account that issued the credential.
pub issuer: Cow<'a, str>,
/// A hex-encoded value identifying the credential type from this issuer.
pub credential_type: Cow<'a, str>,
/// Optional expiration for the credential.
pub expiration: Option<u32>,
/// Optional additional data, represented as a hex-encoded string.
#[serde(rename = "URI")]
pub uri: Option<Cow<'a, str>>,
/// A hint indicating which page of the subject's owner directory links to this object.
/// Omitted for self-issued credentials, which only appear in the issuer's owner directory.
pub subject_node: Option<Cow<'a, str>>,
/// A hint indicating which page of the issuer's owner directory links to this object.
pub issuer_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 containing the transaction that most recently modified this object.
pub previous_txn_lgr_seq: u32,
Comment on lines +51 to +53

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that the rust library is yet to be equipped with many ledger-objects in the future, this is a good time to refactor common fields such as these to prevent code duplication.

previous_txn_lgr_seq and previous_txn_id is found in a huge majority of the ledger objects. These fields should be refactored.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left this unresolved because moving PreviousTxnID/PreviousTxnLgrSeq into ledger-object common fields would be a broader public-model refactor across many existing ledger objects, not just the XLS-70 Credential object. I addressed the concrete XLS-70 validation/test feedback in af71a30 and can handle this as a separate refactor if you want it in this PR.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracked separately in #325.

This is broader than PR #154's XLS-70 credential changes because it involves cross-model public API/request-shape refactors:

  • ledger-object common metadata fields like PreviousTxnID / PreviousTxnLgrSeq
  • request ledger lookup fields via LookupByLedgerRequest

Leaving this PR focused on the XLS-70 compatibility fixes and handling the CommonFields refactor in the follow-up issue.

}

impl<'a> Model for Credential<'a> {}

impl<'a> LedgerObject<CredentialFlag> for Credential<'a> {
fn get_ledger_entry_type(&self) -> LedgerEntryType {
self.common_fields.get_ledger_entry_type()
}
}

impl<'a> Credential<'a> {
#[allow(clippy::too_many_arguments)]
pub fn new(
index: Option<Cow<'a, str>>,
ledger_index: Option<Cow<'a, str>>,
subject: Cow<'a, str>,
issuer: Cow<'a, str>,
credential_type: Cow<'a, str>,
expiration: Option<u32>,
uri: Option<Cow<'a, str>>,
subject_node: Option<Cow<'a, str>>,
issuer_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::Credential,
index,
ledger_index,
},
subject,
issuer,
credential_type,
expiration,
uri,
subject_node,
issuer_node,
previous_txn_id,
previous_txn_lgr_seq,
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_serde() {
let credential = Credential::new(
Some(Cow::from(
"DD40031C6C21164E7673A47C35513D52A6B0F1349A873EE0D188D8994CD4D001",
)),
None,
Cow::from("rALICE1111111111111111111111111111"),
Cow::from("rISABEL111111111111111111111111111"),
Cow::from("4B5943"),
Some(789004799),
Some(Cow::from(
"69736162656C2E636F6D2F63726564656E7469616C732F6B79632F616C696365",
)),
Some(Cow::from("0000000000000000")),
Cow::from("0000000000000000"),
Cow::from("3E8964D5A86B3CD6B9ECB33310D4E073D64C865A5B866200AD2B7E29F8326702"),
8,
);
let serialized = serde_json::to_string(&credential).unwrap();

let deserialized: Credential = serde_json::from_str(&serialized).unwrap();

assert_eq!(credential, deserialized);
}

#[test]
fn test_serde_round_trip_all_fields() {
// Full credential with every field populated
let credential = Credential {
common_fields: CommonFields {
flags: FlagCollection::default(),
ledger_entry_type: LedgerEntryType::Credential,
index: Some(Cow::from(
"DD40031C6C21164E7673A47C35513D52A6B0F1349A873EE0D188D8994CD4D001",
)),
ledger_index: Some(Cow::from("42")),
},
subject: Cow::from("rALICE1111111111111111111111111111"),
issuer: Cow::from("rISABEL111111111111111111111111111"),
credential_type: Cow::from("4B5943"),
expiration: Some(789004799),
uri: Some(Cow::from(
"69736162656C2E636F6D2F63726564656E7469616C732F6B79632F616C696365",
)),
subject_node: Some(Cow::from("0000000000000000")),
issuer_node: Cow::from("0000000000000001"),
previous_txn_id: Cow::from(
"3E8964D5A86B3CD6B9ECB33310D4E073D64C865A5B866200AD2B7E29F8326702",
),
previous_txn_lgr_seq: 8,
};
let json = serde_json::to_string(&credential).unwrap();
let deserialized: Credential = serde_json::from_str(&json).unwrap();
assert_eq!(credential, deserialized);
}

#[test]
fn test_serde_round_trip_optional_fields_omitted() {
// Credential without optional expiration and URI
let credential = Credential {
common_fields: CommonFields {
flags: FlagCollection::default(),
ledger_entry_type: LedgerEntryType::Credential,
index: Some(Cow::from(
"DD40031C6C21164E7673A47C35513D52A6B0F1349A873EE0D188D8994CD4D001",
)),
ledger_index: None,
},
subject: Cow::from("rALICE1111111111111111111111111111"),
issuer: Cow::from("rISABEL111111111111111111111111111"),
credential_type: Cow::from("4B5943"),
expiration: None,
uri: None,
subject_node: Some(Cow::from("0000000000000000")),
issuer_node: Cow::from("0000000000000000"),
previous_txn_id: Cow::from(
"3E8964D5A86B3CD6B9ECB33310D4E073D64C865A5B866200AD2B7E29F8326702",
),
previous_txn_lgr_seq: 5,
};
let json = serde_json::to_string(&credential).unwrap();
// Verify optional fields are absent from serialized JSON
assert!(!json.contains("Expiration"));
assert!(!json.contains("URI"));

let deserialized: Credential = serde_json::from_str(&json).unwrap();
assert_eq!(credential, deserialized);
assert!(deserialized.expiration.is_none());
assert!(deserialized.uri.is_none());
}

#[test]
fn test_self_issued_credential_allows_missing_subject_node() {
let json = r#"{
"LedgerEntryType":"Credential",
"Flags":65536,
"Subject":"rSELF11111111111111111111111111111",
"Issuer":"rSELF11111111111111111111111111111",
"CredentialType":"4B5943",
"IssuerNode":"0000000000000000",
"PreviousTxnID":"3E8964D5A86B3CD6B9ECB33310D4E073D64C865A5B866200AD2B7E29F8326702",
"PreviousTxnLgrSeq":10
}"#;

let credential: Credential = serde_json::from_str(json).unwrap();
assert!(credential.subject_node.is_none());
assert_eq!(credential.subject, credential.issuer);
}

#[test]
fn test_lsf_accepted_flag_value() {
// Verify the lsfAccepted flag has the correct value per the spec: 0x00010000
assert_eq!(CredentialFlag::LsfAccepted as u32, 0x00010000);
}

#[test]
fn test_serde_with_accepted_flag() {
// Credential with the lsfAccepted flag set
let mut flags = FlagCollection::default();
flags.0.push(CredentialFlag::LsfAccepted);
let credential = Credential {
common_fields: CommonFields {
flags,
ledger_entry_type: LedgerEntryType::Credential,
index: Some(Cow::from(
"DD40031C6C21164E7673A47C35513D52A6B0F1349A873EE0D188D8994CD4D001",
)),
ledger_index: None,
},
subject: Cow::from("rALICE1111111111111111111111111111"),
issuer: Cow::from("rISABEL111111111111111111111111111"),
credential_type: Cow::from("4B5943"),
expiration: None,
uri: None,
subject_node: Some(Cow::from("0000000000000000")),
issuer_node: Cow::from("0000000000000000"),
previous_txn_id: Cow::from(
"3E8964D5A86B3CD6B9ECB33310D4E073D64C865A5B866200AD2B7E29F8326702",
),
previous_txn_lgr_seq: 10,
};
let json = serde_json::to_string(&credential).unwrap();
let deserialized: Credential = serde_json::from_str(&json).unwrap();
assert_eq!(credential, deserialized);
}
}
Loading
Loading