From 61d9d692c3426fbf104bc7773177ffc03fe3b78b Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Thu, 30 Apr 2026 12:34:14 -0400 Subject: [PATCH 01/19] add model unit tests --- .github/workflows/unit_test.yml | 6 +- src/models/ledger/objects/bridge.rs | 34 +++ src/models/ledger/objects/mod.rs | 60 +++++ .../ledger/objects/xchain_owned_claim_id.rs | 44 ++++ src/models/requests/account_channels.rs | 23 ++ src/models/requests/account_currencies.rs | 21 ++ src/models/requests/account_lines.rs | 22 ++ src/models/requests/account_objects.rs | 24 ++ src/models/requests/account_offers.rs | 22 ++ src/models/requests/account_tx.rs | 26 +++ src/models/requests/amm_info.rs | 23 ++ src/models/requests/channel_verify.rs | 20 ++ src/models/requests/deposit_authorize.rs | 20 ++ src/models/requests/gateway_balances.rs | 23 ++ src/models/requests/ledger_closed.rs | 14 ++ src/models/requests/ledger_current.rs | 14 ++ src/models/requests/ledger_data.rs | 22 ++ src/models/requests/manifest.rs | 17 ++ src/models/requests/mod.rs | 1 + src/models/requests/nft_buy_offers.rs | 21 ++ src/models/requests/nft_history.rs | 25 +++ src/models/requests/nft_info.rs | 19 ++ src/models/requests/nft_sell_offers.rs | 17 ++ src/models/requests/nfts_by_issuer.rs | 22 ++ src/models/requests/no_ripple_check.rs | 24 +- src/models/requests/path_find.rs | 29 +++ src/models/requests/ping.rs | 23 ++ src/models/requests/random.rs | 14 ++ src/models/requests/ripple_path_find.rs | 32 ++- src/models/requests/server_info.rs | 14 ++ src/models/requests/submit_multisigned.rs | 19 ++ src/models/requests/subscribe.rs | 47 ++++ src/models/requests/transaction_entry.rs | 19 ++ src/models/requests/unsubscribe.rs | 41 ++++ src/models/results/account_tx.rs | 15 +- src/models/results/ledger_entry.rs | 7 +- src/models/results/mod.rs | 205 ++++++++++++++++-- src/models/results/server_info.rs | 26 ++- .../pseudo_transactions/enable_amendment.rs | 51 +++++ .../pseudo_transactions/set_fee.rs | 33 +++ .../pseudo_transactions/unl_modify.rs | 49 +++++ .../xchain_add_claim_attestation.rs | 78 +++++++ src/models/transactions/xchain_commit.rs | 64 ++++++ src/utils/get_xchain_claim_id.rs | 96 ++++++++ src/utils/str_conversion.rs | 44 ++++ tests/common/amm.rs | 7 +- tests/common/mod.rs | 5 +- tests/integration_test.rs | 8 + tests/requests/account_channels.rs | 48 ++++ tests/requests/account_currencies.rs | 45 ++++ tests/requests/account_info.rs | 55 +++++ tests/requests/account_lines.rs | 45 ++++ tests/requests/account_objects.rs | 47 ++++ tests/requests/account_offers.rs | 43 ++++ tests/requests/account_tx.rs | 82 +++++++ tests/requests/amm_info.rs | 43 ++++ tests/requests/book_offers.rs | 45 ++++ tests/requests/channel_verify.rs | 40 ++++ tests/requests/deposit_authorized.rs | 49 +++++ tests/requests/fee.rs | 39 ++++ tests/requests/gateway_balances.rs | 45 ++++ tests/requests/ledger.rs | 48 ++++ tests/requests/ledger_closed.rs | 30 +++ tests/requests/ledger_current.rs | 29 +++ tests/requests/ledger_data.rs | 47 ++++ tests/requests/ledger_entry.rs | 70 ++++++ tests/requests/mod.rs | 27 +++ tests/requests/no_ripple_check.rs | 49 +++++ tests/requests/ping.rs | 23 ++ tests/requests/random.rs | 30 +++ tests/requests/ripple_path_find.rs | 48 ++++ tests/requests/server_info.rs | 36 +++ tests/requests/server_state.rs | 34 +++ tests/requests/submit.rs | 62 ++++++ tests/requests/submit_multisigned.rs | 104 +++++++++ tests/requests/tx.rs | 87 ++++++++ tests/transactions/account_delete.rs | 6 +- tests/transactions/account_set.rs | 2 - tests/transactions/amm_bid.rs | 2 - tests/transactions/amm_create.rs | 4 +- tests/transactions/amm_delete.rs | 2 +- tests/transactions/amm_deposit.rs | 2 - tests/transactions/amm_vote.rs | 2 - tests/transactions/amm_withdraw.rs | 2 - tests/transactions/check_cancel.rs | 2 - tests/transactions/check_cash.rs | 2 - tests/transactions/check_create.rs | 2 - tests/transactions/deposit_preauth.rs | 7 +- tests/transactions/escrow_cancel.rs | 8 +- tests/transactions/escrow_create.rs | 2 - tests/transactions/escrow_finish.rs | 8 +- tests/transactions/nftoken_accept_offer.rs | 3 - tests/transactions/nftoken_burn.rs | 3 - tests/transactions/nftoken_cancel_offer.rs | 3 - tests/transactions/nftoken_create_offer.rs | 2 - tests/transactions/nftoken_mint.rs | 2 - tests/transactions/offer_cancel.rs | 2 - tests/transactions/offer_create.rs | 2 - tests/transactions/payment.rs | 2 - tests/transactions/payment_channel_claim.rs | 6 +- tests/transactions/payment_channel_create.rs | 2 - tests/transactions/payment_channel_fund.rs | 7 +- tests/transactions/set_regular_key.rs | 2 - tests/transactions/signer_list_set.rs | 2 - tests/transactions/ticket_create.rs | 2 - tests/transactions/trust_set.rs | 2 - .../xchain_account_create_commit.rs | 2 - .../xchain_add_account_create_attestation.rs | 2 - .../xchain_add_claim_attestation.rs | 7 +- tests/transactions/xchain_claim.rs | 8 +- tests/transactions/xchain_commit.rs | 2 - tests/transactions/xchain_create_bridge.rs | 2 - tests/transactions/xchain_create_claim_id.rs | 2 - tests/transactions/xchain_modify_bridge.rs | 2 - 114 files changed, 2820 insertions(+), 142 deletions(-) create mode 100644 tests/requests/account_channels.rs create mode 100644 tests/requests/account_currencies.rs create mode 100644 tests/requests/account_info.rs create mode 100644 tests/requests/account_lines.rs create mode 100644 tests/requests/account_objects.rs create mode 100644 tests/requests/account_offers.rs create mode 100644 tests/requests/account_tx.rs create mode 100644 tests/requests/amm_info.rs create mode 100644 tests/requests/book_offers.rs create mode 100644 tests/requests/channel_verify.rs create mode 100644 tests/requests/deposit_authorized.rs create mode 100644 tests/requests/fee.rs create mode 100644 tests/requests/gateway_balances.rs create mode 100644 tests/requests/ledger.rs create mode 100644 tests/requests/ledger_closed.rs create mode 100644 tests/requests/ledger_current.rs create mode 100644 tests/requests/ledger_data.rs create mode 100644 tests/requests/ledger_entry.rs create mode 100644 tests/requests/mod.rs create mode 100644 tests/requests/no_ripple_check.rs create mode 100644 tests/requests/ping.rs create mode 100644 tests/requests/random.rs create mode 100644 tests/requests/ripple_path_find.rs create mode 100644 tests/requests/server_info.rs create mode 100644 tests/requests/server_state.rs create mode 100644 tests/requests/submit.rs create mode 100644 tests/requests/submit_multisigned.rs create mode 100644 tests/requests/tx.rs diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index f9b1acfe..7e110f74 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -47,9 +47,9 @@ jobs: - name: Check coverage thresholds run: | cargo llvm-cov --summary-only \ - --fail-under-lines 73 \ - --fail-under-regions 75 \ - --fail-under-functions 67 + --fail-under-lines 80 \ + --fail-under-regions 78 \ + --fail-under-functions 70 - name: Upload coverage report uses: actions/upload-artifact@v4 diff --git a/src/models/ledger/objects/bridge.rs b/src/models/ledger/objects/bridge.rs index 49b0ed8f..6745db87 100644 --- a/src/models/ledger/objects/bridge.rs +++ b/src/models/ledger/objects/bridge.rs @@ -61,3 +61,37 @@ impl<'a> Bridge<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::currency::XRP; + + #[test] + fn test_bridge_serde_round_trip() { + let bridge = Bridge::new( + Some("AABBCC".into()), + Some("DDEEFF".into()), + "rPV4mZjsXfH2HvUSPLNmqz1J8d3Lpv7tpe".into(), + "100".into(), + 7, + 3, + XChainBridge { + locking_chain_door: "rMAXACCrp3Y8PpswXcg3bKggHX76V3F8M4".into(), + locking_chain_issue: XRP::new().into(), + issuing_chain_door: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + issuing_chain_issue: XRP::new().into(), + }, + "13f".into(), + Some("100000".into()), + ); + let serialized = serde_json::to_string(&bridge).unwrap(); + let deserialized: Bridge = serde_json::from_str(&serialized).unwrap(); + assert_eq!(bridge, deserialized); + assert!(serialized.contains("\"LedgerEntryType\":\"Bridge\"")); + assert!(serialized.contains("\"XChainAccountClaimCount\":7")); + assert!(serialized.contains("\"XChainAccountCreateCount\":3")); + assert!(serialized.contains("\"XChainClaimID\":\"13f\"")); + assert_eq!(bridge.get_ledger_entry_type(), LedgerEntryType::Bridge); + } +} diff --git a/src/models/ledger/objects/mod.rs b/src/models/ledger/objects/mod.rs index 45fa941f..b3b471ea 100644 --- a/src/models/ledger/objects/mod.rs +++ b/src/models/ledger/objects/mod.rs @@ -160,3 +160,63 @@ where fn get_ledger_entry_type(&self) -> LedgerEntryType; } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::amount::XRPAmount; + use crate::models::currency::XRP; + use crate::models::NoFlags; + + #[test] + fn test_common_fields_new() { + let fields: CommonFields<'_, NoFlags> = CommonFields::new( + FlagCollection::default(), + LedgerEntryType::Bridge, + Some("AABBCC".into()), + Some("DDEEFF".into()), + ); + assert_eq!(fields.get_ledger_entry_type(), LedgerEntryType::Bridge); + // The default impl on `LedgerObject` should return `false` here - no + // flag set is true. Pull a flag from the enum to satisfy IntoEnumIter. + // Because NoFlags has no variants, simply check the trait wiring works. + assert_eq!(fields.flags.0.len(), 0); + } + + #[test] + fn test_ledger_entry_type_display() { + // The Display impl is auto-derived from `strum_macros::Display`. + assert_eq!(LedgerEntryType::AccountRoot.to_string(), "AccountRoot"); + assert_eq!(LedgerEntryType::Bridge.to_string(), "Bridge"); + assert_eq!( + LedgerEntryType::XChainOwnedClaimID.to_string(), + "XChainOwnedClaimID" + ); + } + + #[test] + fn test_ledger_entry_enum_serde_round_trip() { + let bridge = Bridge::new( + Some("AABBCC".into()), + Some("DDEEFF".into()), + "rPV4mZjsXfH2HvUSPLNmqz1J8d3Lpv7tpe".into(), + XRPAmount::from("100"), + 0, + 0, + crate::models::XChainBridge { + locking_chain_door: "rMAXACCrp3Y8PpswXcg3bKggHX76V3F8M4".into(), + locking_chain_issue: XRP::new().into(), + issuing_chain_door: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + issuing_chain_issue: XRP::new().into(), + }, + "1".into(), + None, + ); + let entry = LedgerEntry::Bridge(bridge); + let serialized = serde_json::to_string(&entry).unwrap(); + // The enum is untagged-ish; default behaviour is externally tagged on + // variant name. Either way, round-trip must work for the same JSON. + let deserialized: LedgerEntry = serde_json::from_str(&serialized).unwrap(); + assert_eq!(entry, deserialized); + } +} diff --git a/src/models/ledger/objects/xchain_owned_claim_id.rs b/src/models/ledger/objects/xchain_owned_claim_id.rs index 12e13276..d2d14a77 100644 --- a/src/models/ledger/objects/xchain_owned_claim_id.rs +++ b/src/models/ledger/objects/xchain_owned_claim_id.rs @@ -57,3 +57,47 @@ impl<'a> XChainOwnedClaimID<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::amount::XRPAmount; + use crate::models::currency::XRP; + use alloc::vec; + + #[test] + fn test_xchain_owned_claim_id_serde_round_trip() { + let attestation = XChainClaimProofSig { + amount: Amount::XRPAmount(XRPAmount::from("10000")), + attestation_reward_account: "rPV4mZjsXfH2HvUSPLNmqz1J8d3Lpv7tpe".into(), + attestation_signer_account: "rPV4mZjsXfH2HvUSPLNmqz1J8d3Lpv7tpe".into(), + destination: "rDest11111111111111111111111111111".into(), + public_key: "ED1234567890ABCDEF".into(), + was_locking_chain_send: 1, + }; + let entry = XChainOwnedClaimID::new( + Some("AABBCC".into()), + None, + "rPV4mZjsXfH2HvUSPLNmqz1J8d3Lpv7tpe".into(), + "rSrc111111111111111111111111111111".into(), + Amount::XRPAmount(XRPAmount::from("100")), + XChainBridge { + locking_chain_door: "rMAXACCrp3Y8PpswXcg3bKggHX76V3F8M4".into(), + locking_chain_issue: XRP::new().into(), + issuing_chain_door: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + issuing_chain_issue: XRP::new().into(), + }, + vec![attestation], + "13f".into(), + ); + let serialized = serde_json::to_string(&entry).unwrap(); + let deserialized: XChainOwnedClaimID = serde_json::from_str(&serialized).unwrap(); + assert_eq!(entry, deserialized); + assert!(serialized.contains("\"LedgerEntryType\":\"XChainOwnedClaimID\"")); + assert!(serialized.contains("\"XChainClaimAttestations\"")); + assert_eq!( + entry.get_ledger_entry_type(), + LedgerEntryType::XChainOwnedClaimID + ); + } +} diff --git a/src/models/requests/account_channels.rs b/src/models/requests/account_channels.rs index 80f52315..4e91f178 100644 --- a/src/models/requests/account_channels.rs +++ b/src/models/requests/account_channels.rs @@ -95,3 +95,26 @@ impl<'a> Request<'a> for AccountChannels<'a> { &mut self.common_fields } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = AccountChannels::new( + Some("ac-1".into()), + "rH6ZiHU1PGamME2LvVTxrgvfjQpppWKGmr".into(), + Some("rDest11111111111111111111111111111".into()), + None, + Some(LedgerIndex::Str("validated".into())), + Some(50), + Some(Marker::Int(12345)), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: AccountChannels = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"account_channels\"")); + assert!(serialized.contains("\"account\":\"rH6ZiHU1PGamME2LvVTxrgvfjQpppWKGmr\"")); + } +} diff --git a/src/models/requests/account_currencies.rs b/src/models/requests/account_currencies.rs index a9b91112..a66dec5e 100644 --- a/src/models/requests/account_currencies.rs +++ b/src/models/requests/account_currencies.rs @@ -67,3 +67,24 @@ impl<'a> AccountCurrencies<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = AccountCurrencies::new( + Some("acur-1".into()), + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + None, + Some(LedgerIndex::Int(123)), + Some(true), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: AccountCurrencies = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"account_currencies\"")); + assert!(serialized.contains("\"strict\":true")); + } +} diff --git a/src/models/requests/account_lines.rs b/src/models/requests/account_lines.rs index ba2bd179..f4344eb4 100644 --- a/src/models/requests/account_lines.rs +++ b/src/models/requests/account_lines.rs @@ -70,3 +70,25 @@ impl<'a> AccountLines<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = AccountLines::new( + Some("al-1".into()), + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + None, + Some(LedgerIndex::Str("validated".into())), + Some(100), + Some("rPeer11111111111111111111111111111".into()), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: AccountLines = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"account_lines\"")); + assert!(serialized.contains("\"limit\":100")); + } +} diff --git a/src/models/requests/account_objects.rs b/src/models/requests/account_objects.rs index 11060adf..a3fd20c5 100644 --- a/src/models/requests/account_objects.rs +++ b/src/models/requests/account_objects.rs @@ -98,3 +98,27 @@ impl<'a> AccountObjects<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = AccountObjects::new( + Some("ao-1".into()), + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + None, + None, + Some(AccountObjectType::Escrow), + Some(true), + Some(20), + None, + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: AccountObjects = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"account_objects\"")); + assert!(serialized.contains("\"type\":\"escrow\"")); + } +} diff --git a/src/models/requests/account_offers.rs b/src/models/requests/account_offers.rs index c5880dd3..291bd5a8 100644 --- a/src/models/requests/account_offers.rs +++ b/src/models/requests/account_offers.rs @@ -74,3 +74,25 @@ impl<'a> AccountOffers<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = AccountOffers::new( + Some("aoff-1".into()), + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + None, + Some(LedgerIndex::Int(456)), + Some(50), + Some(true), + Some(Marker::Str("abc".into())), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: AccountOffers = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"account_offers\"")); + } +} diff --git a/src/models/requests/account_tx.rs b/src/models/requests/account_tx.rs index 3082a913..c1ab7554 100644 --- a/src/models/requests/account_tx.rs +++ b/src/models/requests/account_tx.rs @@ -94,3 +94,29 @@ impl<'a> AccountTx<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = AccountTx::new( + Some("atx-1".into()), + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + None, + None, + Some(false), + Some(true), + Some(1), + Some(99999), + Some(25), + None, + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: AccountTx = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"account_tx\"")); + assert!(serialized.contains("\"forward\":true")); + } +} diff --git a/src/models/requests/amm_info.rs b/src/models/requests/amm_info.rs index 2c667a9c..47ec2a4f 100644 --- a/src/models/requests/amm_info.rs +++ b/src/models/requests/amm_info.rs @@ -47,3 +47,26 @@ impl<'a> AMMInfo<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::currency::{IssuedCurrency, XRP}; + + #[test] + fn test_serde_round_trip() { + let req = AMMInfo::new( + Some("amm-1".into()), + Some("rAMM1111111111111111111111111111111".into()), + Some(Currency::XRP(XRP::new())), + Some(Currency::IssuedCurrency(IssuedCurrency::new( + "USD".into(), + "rIssuer11111111111111111111111111".into(), + ))), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: AMMInfo = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"amm_info\"")); + } +} diff --git a/src/models/requests/channel_verify.rs b/src/models/requests/channel_verify.rs index 59e814a9..e3651355 100644 --- a/src/models/requests/channel_verify.rs +++ b/src/models/requests/channel_verify.rs @@ -60,3 +60,23 @@ impl<'a> ChannelVerify<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = ChannelVerify::new( + Some("cv-1".into()), + "1000000".into(), + "5DB01B7FFED6B67E6B0414DED11E051D2EE2B7619CE0EAA6286D67A3A4D5BDB3".into(), + "aBQG8RQAzjs1eTKFEAQXr2gS4utcDiEC9wmi7pfUPTi27VCahwgw".into(), + "30440220...".into(), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: ChannelVerify = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"channel_verify\"")); + } +} diff --git a/src/models/requests/deposit_authorize.rs b/src/models/requests/deposit_authorize.rs index 1c83842d..c115d656 100644 --- a/src/models/requests/deposit_authorize.rs +++ b/src/models/requests/deposit_authorize.rs @@ -60,3 +60,23 @@ impl<'a> DepositAuthorized<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = DepositAuthorized::new( + Some("da-1".into()), + "rDest11111111111111111111111111111".into(), + "rSrc111111111111111111111111111111".into(), + None, + Some(LedgerIndex::Str("validated".into())), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: DepositAuthorized = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"deposit_authorized\"")); + } +} diff --git a/src/models/requests/gateway_balances.rs b/src/models/requests/gateway_balances.rs index cb91184d..68b23a35 100644 --- a/src/models/requests/gateway_balances.rs +++ b/src/models/requests/gateway_balances.rs @@ -68,3 +68,26 @@ impl<'a> GatewayBalances<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + #[test] + fn test_serde_round_trip() { + let req = GatewayBalances::new( + Some("gb-1".into()), + "rIssuer11111111111111111111111111".into(), + Some(vec!["rHot1111111111111111111111111111".into()]), + None, + Some(LedgerIndex::Str("validated".into())), + Some(true), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: GatewayBalances = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"gateway_balances\"")); + assert!(serialized.contains("\"hotwallet\"")); + } +} diff --git a/src/models/requests/ledger_closed.rs b/src/models/requests/ledger_closed.rs index 7b601ea6..d948ab45 100644 --- a/src/models/requests/ledger_closed.rs +++ b/src/models/requests/ledger_closed.rs @@ -42,3 +42,17 @@ impl<'a> LedgerClosed<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = LedgerClosed::new(Some("lc-1".into())); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: LedgerClosed = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"ledger_closed\"")); + } +} diff --git a/src/models/requests/ledger_current.rs b/src/models/requests/ledger_current.rs index aec482f6..f9a7cbe3 100644 --- a/src/models/requests/ledger_current.rs +++ b/src/models/requests/ledger_current.rs @@ -42,3 +42,17 @@ impl<'a> LedgerCurrent<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = LedgerCurrent::new(Some("lcur-1".into())); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: LedgerCurrent = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"ledger_current\"")); + } +} diff --git a/src/models/requests/ledger_data.rs b/src/models/requests/ledger_data.rs index 6975eedf..7ca77daa 100644 --- a/src/models/requests/ledger_data.rs +++ b/src/models/requests/ledger_data.rs @@ -68,3 +68,25 @@ impl<'a> LedgerData<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = LedgerData::new( + Some("ld-1".into()), + Some(true), + None, + Some(LedgerIndex::Int(42)), + Some(75), + Some(Marker::Int(7)), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: LedgerData = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"ledger_data\"")); + assert!(serialized.contains("\"binary\":true")); + } +} diff --git a/src/models/requests/manifest.rs b/src/models/requests/manifest.rs index e9f38d94..2af71342 100644 --- a/src/models/requests/manifest.rs +++ b/src/models/requests/manifest.rs @@ -48,3 +48,20 @@ impl<'a> Manifest<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = Manifest::new( + Some("mf-1".into()), + "nHUFE9prPXPrHcG3SkwP1UzAQbSphqyQkQK9ATXLZsfkezhhda3p".into(), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: Manifest = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"manifest\"")); + } +} diff --git a/src/models/requests/mod.rs b/src/models/requests/mod.rs index 3493a183..9fe2c9bb 100644 --- a/src/models/requests/mod.rs +++ b/src/models/requests/mod.rs @@ -62,6 +62,7 @@ pub enum RequestMethod { #[serde(rename = "amm_info")] AMMInfo, GatewayBalances, + #[serde(rename = "noripple_check")] NoRippleCheck, // Transaction methods diff --git a/src/models/requests/nft_buy_offers.rs b/src/models/requests/nft_buy_offers.rs index c5d2cfed..83f098fb 100644 --- a/src/models/requests/nft_buy_offers.rs +++ b/src/models/requests/nft_buy_offers.rs @@ -66,3 +66,24 @@ impl<'a> NftBuyOffers<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = NftBuyOffers::new( + Some("nbo-1".into()), + "00080000B4F4AFC5FBCBD76873F18006173D2193467D3EE70000099B00000000".into(), + None, + Some(LedgerIndex::Str("validated".into())), + Some(100), + None, + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: NftBuyOffers = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"nft_buy_offers\"")); + } +} diff --git a/src/models/requests/nft_history.rs b/src/models/requests/nft_history.rs index 4d165b7f..286e95ca 100644 --- a/src/models/requests/nft_history.rs +++ b/src/models/requests/nft_history.rs @@ -72,3 +72,28 @@ impl<'a> NFTHistory<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = NFTHistory::new( + Some("nh-1".into()), + "00080000B4F4AFC5FBCBD76873F18006173D2193467D3EE70000099B00000000".into(), + None, + None, + Some(1), + Some(5000), + Some(false), + Some(true), + Some(50), + None, + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: NFTHistory = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"nft_history\"")); + } +} diff --git a/src/models/requests/nft_info.rs b/src/models/requests/nft_info.rs index 4609657d..274813f2 100644 --- a/src/models/requests/nft_info.rs +++ b/src/models/requests/nft_info.rs @@ -54,3 +54,22 @@ impl<'a> NFTInfo<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = NFTInfo::new( + Some("ni-1".into()), + "00080000B4F4AFC5FBCBD76873F18006173D2193467D3EE70000099B00000000".into(), + None, + Some(LedgerIndex::Str("validated".into())), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: NFTInfo = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"nft_info\"")); + } +} diff --git a/src/models/requests/nft_sell_offers.rs b/src/models/requests/nft_sell_offers.rs index 08d294ba..0c42666e 100644 --- a/src/models/requests/nft_sell_offers.rs +++ b/src/models/requests/nft_sell_offers.rs @@ -43,3 +43,20 @@ impl<'a> NftSellOffers<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = NftSellOffers::new( + Some("nso-1".into()), + "00080000B4F4AFC5FBCBD76873F18006173D2193467D3EE70000099B00000000".into(), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: NftSellOffers = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"nft_sell_offers\"")); + } +} diff --git a/src/models/requests/nfts_by_issuer.rs b/src/models/requests/nfts_by_issuer.rs index d7b20edd..11d476b6 100644 --- a/src/models/requests/nfts_by_issuer.rs +++ b/src/models/requests/nfts_by_issuer.rs @@ -63,3 +63,25 @@ impl<'a> NFTsByIssuer<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = NFTsByIssuer::new( + Some("nbi-1".into()), + "rIssuer11111111111111111111111111".into(), + None, + None, + Some(100), + Some(Marker::Int(1)), + Some(42), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: NFTsByIssuer = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"nfts_by_issuer\"")); + } +} diff --git a/src/models/requests/no_ripple_check.rs b/src/models/requests/no_ripple_check.rs index 854d89de..3ca1cbdf 100644 --- a/src/models/requests/no_ripple_check.rs +++ b/src/models/requests/no_ripple_check.rs @@ -12,7 +12,6 @@ use super::{CommonFields, LedgerIndex, LookupByLedgerRequest, Request}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Display)] #[strum(serialize_all = "snake_case")] #[serde(rename_all = "snake_case")] -#[serde(tag = "role")] #[derive(Default)] pub enum NoRippleCheckRole { #[default] @@ -92,3 +91,26 @@ impl<'a> NoRippleCheck<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = NoRippleCheck::new( + Some("nrc-1".into()), + "rIssuer11111111111111111111111111".into(), + NoRippleCheckRole::Gateway, + None, + Some(LedgerIndex::Str("validated".into())), + Some(100), + Some(true), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: NoRippleCheck = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"noripple_check\"")); + assert!(serialized.contains("\"role\":\"gateway\"")); + } +} diff --git a/src/models/requests/path_find.rs b/src/models/requests/path_find.rs index 55b34865..fe7a0c79 100644 --- a/src/models/requests/path_find.rs +++ b/src/models/requests/path_find.rs @@ -124,3 +124,32 @@ impl<'a> PathFind<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::currency::XRP; + + #[test] + fn test_serde_round_trip() { + let req = PathFind::new( + Some("pf-1".into()), + "rDest11111111111111111111111111111".into(), + Currency::XRP(XRP::new()), + "rSrc111111111111111111111111111111".into(), + PathFindSubcommand::Create, + None, + Some(Currency::XRP(XRP::new())), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: PathFind = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"path_find\"")); + assert!(serialized.contains("\"subcommand\":\"create\"")); + } + + #[test] + fn test_subcommand_default() { + assert_eq!(PathFindSubcommand::default(), PathFindSubcommand::Create); + } +} diff --git a/src/models/requests/ping.rs b/src/models/requests/ping.rs index 5c70e863..38c4a445 100644 --- a/src/models/requests/ping.rs +++ b/src/models/requests/ping.rs @@ -41,3 +41,26 @@ impl<'a> Ping<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use alloc::string::ToString; + + #[test] + fn test_serde_round_trip() { + let req = Ping::new(Some("ping-1".into())); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: Ping = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"ping\"")); + } + + #[test] + fn test_get_common_fields() { + let mut req = Ping::new(None); + assert!(req.get_common_fields().id.is_none()); + req.get_common_fields_mut().id = Some("x".to_string().into()); + assert_eq!(req.get_common_fields().id.as_deref(), Some("x")); + } +} diff --git a/src/models/requests/random.rs b/src/models/requests/random.rs index 19c7e986..7f9b209c 100644 --- a/src/models/requests/random.rs +++ b/src/models/requests/random.rs @@ -42,3 +42,17 @@ impl<'a> Random<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = Random::new(Some("rand-1".into())); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: Random = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"random\"")); + } +} diff --git a/src/models/requests/ripple_path_find.rs b/src/models/requests/ripple_path_find.rs index 1ac23f70..0df85ebf 100644 --- a/src/models/requests/ripple_path_find.rs +++ b/src/models/requests/ripple_path_find.rs @@ -3,7 +3,7 @@ use alloc::vec::Vec; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; -use crate::models::{currency::Currency, requests::RequestMethod, Model}; +use crate::models::{amount::Amount, currency::Currency, requests::RequestMethod, Model}; use super::{CommonFields, LedgerIndex, LookupByLedgerRequest, Request}; @@ -38,7 +38,7 @@ pub struct RipplePathFind<'a> { /// of the value field (for non-XRP currencies). This requests a /// path to deliver as much as possible, while spending no more /// than the amount specified in send_max (if provided). - pub destination_amount: Currency<'a>, + pub destination_amount: Amount<'a>, /// Unique address of the account that would send funds /// in a transaction. pub source_account: Cow<'a, str>, @@ -74,7 +74,7 @@ impl<'a> RipplePathFind<'a> { pub fn new( id: Option>, destination_account: Cow<'a, str>, - destination_amount: Currency<'a>, + destination_amount: Amount<'a>, source_account: Cow<'a, str>, ledger_hash: Option>, ledger_index: Option>, @@ -98,3 +98,29 @@ impl<'a> RipplePathFind<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::amount::XRPAmount; + use crate::models::currency::XRP; + use alloc::vec; + + #[test] + fn test_serde_round_trip() { + let req = RipplePathFind::new( + Some("rpf-1".into()), + "rDest11111111111111111111111111111".into(), + Amount::XRPAmount(XRPAmount::from("1000000")), + "rSrc111111111111111111111111111111".into(), + None, + Some(LedgerIndex::Str("validated".into())), + Some(Currency::XRP(XRP::new())), + Some(vec![Currency::XRP(XRP::new())]), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: RipplePathFind = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"ripple_path_find\"")); + } +} diff --git a/src/models/requests/server_info.rs b/src/models/requests/server_info.rs index 7044ee5d..5729574c 100644 --- a/src/models/requests/server_info.rs +++ b/src/models/requests/server_info.rs @@ -42,3 +42,17 @@ impl<'a> ServerInfo<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = ServerInfo::new(Some("si-1".into())); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: ServerInfo = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"server_info\"")); + } +} diff --git a/src/models/requests/submit_multisigned.rs b/src/models/requests/submit_multisigned.rs index 35cc182f..a14f2d80 100644 --- a/src/models/requests/submit_multisigned.rs +++ b/src/models/requests/submit_multisigned.rs @@ -57,3 +57,22 @@ impl<'a> SubmitMultisigned<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let tx_json = serde_json::json!({ + "TransactionType": "Payment", + "Account": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + }); + let req = SubmitMultisigned::new(Some("sm-1".into()), tx_json, Some(false)); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: SubmitMultisigned = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"submit_multisigned\"")); + assert!(serialized.contains("\"fail_hard\":false")); + } +} diff --git a/src/models/requests/subscribe.rs b/src/models/requests/subscribe.rs index fb501f87..9dd6115a 100644 --- a/src/models/requests/subscribe.rs +++ b/src/models/requests/subscribe.rs @@ -113,3 +113,50 @@ impl<'a> Subscribe<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::currency::{IssuedCurrency, XRP}; + use alloc::vec; + + #[test] + fn test_serde_round_trip_no_books() { + // SubscribeBook uses asymmetric serialize/deserialize naming + // (PascalCase vs snake_case), so a full round-trip with books does + // not work. Round-trip the request without books. + let req = Subscribe::new( + Some("sub-1".into()), + Some(vec!["rAcc1111111111111111111111111111".into()]), + None, + None, + Some(vec![StreamParameter::Ledger, StreamParameter::Transactions]), + Some("https://example.test/cb".into()), + None, + None, + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: Subscribe = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"subscribe\"")); + assert!(serialized.contains("\"streams\"")); + } + + #[test] + fn test_subscribe_book_serializes() { + let book = SubscribeBook::new( + "rTaker1111111111111111111111111111".into(), + Currency::XRP(XRP::new()), + Currency::IssuedCurrency(IssuedCurrency::new( + "USD".into(), + "rIssuer11111111111111111111111111".into(), + )), + Some(true), + Some(false), + ); + let serialized = serde_json::to_string(&book).unwrap(); + assert!(serialized.contains("\"Taker\"")); + assert!(serialized.contains("\"TakerGets\"")); + assert!(serialized.contains("\"TakerPays\"")); + } +} diff --git a/src/models/requests/transaction_entry.rs b/src/models/requests/transaction_entry.rs index 6d466d81..339fabb5 100644 --- a/src/models/requests/transaction_entry.rs +++ b/src/models/requests/transaction_entry.rs @@ -59,3 +59,22 @@ impl<'a> TransactionEntry<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let req = TransactionEntry::new( + Some("te-1".into()), + "C53ECF838647FA5A4C780377025FEC7999AB4182590510CA461444B207AB74A9".into(), + None, + Some(LedgerIndex::Int(56865245)), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: TransactionEntry = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"transaction_entry\"")); + } +} diff --git a/src/models/requests/unsubscribe.rs b/src/models/requests/unsubscribe.rs index 8ea56076..790b8245 100644 --- a/src/models/requests/unsubscribe.rs +++ b/src/models/requests/unsubscribe.rs @@ -94,3 +94,44 @@ impl<'a> Unsubscribe<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::currency::{IssuedCurrency, XRP}; + use alloc::vec; + + #[test] + fn test_serde_round_trip_no_books() { + // UnsubscribeBook uses asymmetric serialize/deserialize naming + // (PascalCase vs snake_case), so a full round-trip with books does + // not work. Round-trip the request without books. + let req = Unsubscribe::new( + Some("uns-1".into()), + Some(vec!["rAcc1111111111111111111111111111".into()]), + None, + None, + None, + Some(vec![StreamParameter::Ledger]), + ); + let serialized = serde_json::to_string(&req).unwrap(); + let deserialized: Unsubscribe = serde_json::from_str(&serialized).unwrap(); + assert_eq!(req, deserialized); + assert!(serialized.contains("\"command\":\"unsubscribe\"")); + } + + #[test] + fn test_unsubscribe_book_serializes() { + let book = UnsubscribeBook::new( + Currency::XRP(XRP::new()), + Currency::IssuedCurrency(IssuedCurrency::new( + "USD".into(), + "rIssuer11111111111111111111111111".into(), + )), + Some(true), + ); + let serialized = serde_json::to_string(&book).unwrap(); + assert!(serialized.contains("\"TakerGets\"")); + assert!(serialized.contains("\"TakerPays\"")); + } +} diff --git a/src/models/results/account_tx.rs b/src/models/results/account_tx.rs index 35d57834..065778f7 100644 --- a/src/models/results/account_tx.rs +++ b/src/models/results/account_tx.rs @@ -77,10 +77,12 @@ pub struct AccountTxV1<'a> { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct TransactionBase<'a> { /// The ledger index of the ledger version that included this transaction. - pub ledger_index: u32, + /// In API v1 this field is inside the `tx` object rather than at the + /// transaction-entry level, so it may be absent here. + pub ledger_index: Option, /// Whether or not the transaction is included in a validated ledger. Any /// transaction not yet in a validated ledger is subject to change. - pub validated: bool, + pub validated: Option, /// (Binary mode) A unique hex string defining the transaction. pub tx_blob: Option>, } @@ -103,8 +105,10 @@ pub struct AccountTxTransaction<'a> { pub struct AccountTxTransactionV1<'a> { #[serde(flatten)] pub base: TransactionBase<'a>, - /// (Binary mode) A hex string of the transaction in binary format. - pub tx: Cow<'a, str>, + /// (JSON mode) JSON object defining the transaction (API v1). + pub tx: Option, + /// (JSON mode) The transaction results metadata in JSON. + pub meta: Option>, } impl<'a> TryFrom> for AccountTxVersionMap<'a> { @@ -113,6 +117,9 @@ impl<'a> TryFrom> for AccountTxVersionMap<'a> { fn try_from(result: XRPLResult<'a>) -> XRPLModelResult { match result { XRPLResult::AccountTx(account_tx) => Ok(account_tx), + XRPLResult::Other(super::XRPLOtherResult(ref value)) => { + serde_json::from_value(value.clone()).map_err(Into::into) + } res => Err(XRPLResultException::UnexpectedResultType( "AccountTx".to_string(), res.get_name(), diff --git a/src/models/results/ledger_entry.rs b/src/models/results/ledger_entry.rs index 5f38fddf..830b5c40 100644 --- a/src/models/results/ledger_entry.rs +++ b/src/models/results/ledger_entry.rs @@ -14,9 +14,10 @@ pub struct Node<'a> { /// The identifying address of this account pub account: Cow<'a, str>, /// The identifying hash of the transaction that most recently modified - /// this object + /// this object. Only present if the account has the asfAccountTxnID flag + /// enabled. #[serde(rename = "AccountTxnID")] - pub account_txn_id: Cow<'a, str>, + pub account_txn_id: Option>, /// The account's current XRP balance in drops pub balance: Cow<'a, str>, /// The domain associated with this account. The raw domain value is a @@ -127,7 +128,7 @@ mod tests { assert_eq!(node.account, "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"); assert_eq!( node.account_txn_id, - "4E0AA11CBDD1760DE95B68DF2ABBE75C9698CEB548BEA9789053FCB3EBD444FB" + Some("4E0AA11CBDD1760DE95B68DF2ABBE75C9698CEB548BEA9789053FCB3EBD444FB".into()) ); assert_eq!(node.balance, "424021949"); assert_eq!(node.domain, Some("6D64756F31332E636F6D".into())); diff --git a/src/models/results/mod.rs b/src/models/results/mod.rs index 6d06dc5f..f1802ec8 100644 --- a/src/models/results/mod.rs +++ b/src/models/results/mod.rs @@ -279,8 +279,40 @@ impl_try_from_result!(gateway_balances, GatewayBalances, GatewayBalances); impl_try_from_result!(ledger, Ledger, Ledger); impl_try_from_result!(ledger_closed, LedgerClosed, LedgerClosed); impl_try_from_result!(ledger_current, LedgerCurrent, LedgerCurrent); -impl_try_from_result!(ledger_data, LedgerData, LedgerData); -impl_try_from_result!(ledger_entry, LedgerEntry, LedgerEntry); +// LedgerData: serde's untagged enum may match LedgerClosed (which has fewer +// required fields) before reaching LedgerData. Re-serialize and re-parse +// to recover the full data. +impl<'a> TryFrom> for ledger_data::LedgerData<'a> { + type Error = XRPLModelException; + fn try_from(result: XRPLResult<'a>) -> XRPLModelResult { + match result { + XRPLResult::LedgerData(value) => Ok(value), + XRPLResult::Other(XRPLOtherResult(ref value)) => { + serde_json::from_value(value.clone()).map_err(Into::into) + } + other => { + let value = serde_json::to_value(&other)?; + serde_json::from_value(value).map_err(Into::into) + } + } + } +} +// LedgerEntry: may match Ledger or LedgerClosed before reaching LedgerEntry. +impl<'a> TryFrom> for ledger_entry::LedgerEntry<'a> { + type Error = XRPLModelException; + fn try_from(result: XRPLResult<'a>) -> XRPLModelResult { + match result { + XRPLResult::LedgerEntry(value) => Ok(value), + XRPLResult::Other(XRPLOtherResult(ref value)) => { + serde_json::from_value(value.clone()).map_err(Into::into) + } + other => { + let value = serde_json::to_value(&other)?; + serde_json::from_value(value).map_err(Into::into) + } + } + } +} impl_try_from_result!(manifest, Manifest, Manifest); // NFTBuyOffers and NFTSellOffers are structurally identical; the untagged enum // always picks the first matching variant (NFTBuyOffers). Both TryFrom impls @@ -325,16 +357,49 @@ impl<'a> TryFrom> for nft_sell_offers::NFTSellOffers<'a> { } } impl_try_from_result!(nftoken, NFTokenMintResult, NFTokenMintResult); -impl_try_from_result!(no_ripple_check, NoRippleCheck, NoRippleCheck); +// NoRippleCheck: may match Other(Value) due to untagged enum ordering. +impl<'a> TryFrom> for no_ripple_check::NoRippleCheck<'a> { + type Error = XRPLModelException; + fn try_from(result: XRPLResult<'a>) -> XRPLModelResult { + match result { + XRPLResult::NoRippleCheck(value) => Ok(value), + XRPLResult::Other(XRPLOtherResult(ref value)) => { + serde_json::from_value(value.clone()).map_err(Into::into) + } + other => { + let value = serde_json::to_value(&other)?; + serde_json::from_value(value).map_err(Into::into) + } + } + } +} impl_try_from_result!(path_find, PathFind, PathFind); impl_try_from_result!(random, Random, Random); -impl_try_from_result!(ripple_path_find, RipplePathFind, RipplePathFind); +// RipplePathFind: may match LedgerCurrent due to untagged enum ordering. +impl<'a> TryFrom> for ripple_path_find::RipplePathFind<'a> { + type Error = XRPLModelException; + fn try_from(result: XRPLResult<'a>) -> XRPLModelResult { + match result { + XRPLResult::RipplePathFind(value) => Ok(value), + XRPLResult::Other(XRPLOtherResult(ref value)) => { + serde_json::from_value(value.clone()).map_err(Into::into) + } + other => { + let value = serde_json::to_value(&other)?; + serde_json::from_value(value).map_err(Into::into) + } + } + } +} impl<'a> TryFrom> for server_info::ServerInfo<'a> { type Error = XRPLModelException; fn try_from(result: XRPLResult<'a>) -> XRPLModelResult { match result { XRPLResult::ServerInfo(value) => Ok(*value), + XRPLResult::Other(XRPLOtherResult(ref value)) => { + serde_json::from_value(value.clone()).map_err(Into::into) + } res => Err(XRPLResultException::UnexpectedResultType( "ServerInfo".to_string(), res.get_name(), @@ -358,7 +423,22 @@ impl<'a> TryFrom> for server_state::ServerState<'a> { } } impl_try_from_result!(submit, Submit, Submit); -impl_try_from_result!(submit_multisigned, SubmitMultisigned, SubmitMultisigned); +// SubmitMultisigned: may match Submit due to untagged enum ordering. +impl<'a> TryFrom> for submit_multisigned::SubmitMultisigned<'a> { + type Error = XRPLModelException; + fn try_from(result: XRPLResult<'a>) -> XRPLModelResult { + match result { + XRPLResult::SubmitMultisigned(value) => Ok(value), + XRPLResult::Other(XRPLOtherResult(ref value)) => { + serde_json::from_value(value.clone()).map_err(Into::into) + } + other => { + let value = serde_json::to_value(&other)?; + serde_json::from_value(value).map_err(Into::into) + } + } + } +} impl_try_from_result!(transaction_entry, TransactionEntry, TransactionEntry); impl_try_from_result!(ping, Ping, Ping); impl_try_from_result!(subscribe, Subscribe, Subscribe); @@ -445,6 +525,10 @@ pub struct XRPLResponse<'a> { pub forwarded: Option, pub request: Option>, pub result: Option>, + /// Raw JSON of the `result` field, preserved for fallback re-deserialization + /// when the untagged `XRPLResult` enum matches the wrong variant. + #[serde(skip)] + pub raw_result: Option, pub status: Option, pub r#type: Option, pub warning: Option>, @@ -517,8 +601,43 @@ impl_try_from_response!(gateway_balances, GatewayBalances, GatewayBalances); impl_try_from_response!(ledger, Ledger, Ledger); impl_try_from_response!(ledger_closed, LedgerClosed, LedgerClosed); impl_try_from_response!(ledger_current, LedgerCurrent, LedgerCurrent); -impl_try_from_response!(ledger_data, LedgerData, LedgerData); -impl_try_from_response!(ledger_entry, LedgerEntry, LedgerEntry); +// LedgerData / LedgerEntry: use raw_result fallback for untagged enum +// mismatch where serde picks a wrong variant and loses fields. +impl<'a, 'b> TryFrom> for ledger_data::LedgerData<'b> +where + 'a: 'b, + 'b: 'a, +{ + type Error = XRPLModelException; + fn try_from(response: XRPLResponse<'a>) -> XRPLModelResult { + match response.result { + Some(XRPLResult::LedgerData(value)) => return Ok(value), + _ => {} + } + // Fallback: re-deserialize from the raw result JSON + match response.raw_result { + Some(raw) => serde_json::from_value(raw).map_err(Into::into), + None => Err(XRPLModelException::MissingField("result".to_string())), + } + } +} +impl<'a, 'b> TryFrom> for ledger_entry::LedgerEntry<'b> +where + 'a: 'b, + 'b: 'a, +{ + type Error = XRPLModelException; + fn try_from(response: XRPLResponse<'a>) -> XRPLModelResult { + match response.result { + Some(XRPLResult::LedgerEntry(value)) => return Ok(value), + _ => {} + } + match response.raw_result { + Some(raw) => serde_json::from_value(raw).map_err(Into::into), + None => Err(XRPLModelException::MissingField("result".to_string())), + } + } +} impl_try_from_response!(manifest, Manifest, Manifest); impl_try_from_response!(nft_info, NFTInfo, NFTInfo); // NFTBuyOffers and NFTSellOffers are structurally identical; the untagged enum @@ -576,11 +695,45 @@ where } } impl_try_from_response!(nftoken, NFTokenMintResult, NFTokenMintResult); -impl_try_from_response!(no_ripple_check, NoRippleCheck, NoRippleCheck); +// NoRippleCheck / RipplePathFind: use raw_result fallback for untagged +// enum mismatch. +impl<'a, 'b> TryFrom> for no_ripple_check::NoRippleCheck<'b> +where + 'a: 'b, + 'b: 'a, +{ + type Error = XRPLModelException; + fn try_from(response: XRPLResponse<'a>) -> XRPLModelResult { + match response.result { + Some(XRPLResult::NoRippleCheck(value)) => return Ok(value), + _ => {} + } + match response.raw_result { + Some(raw) => serde_json::from_value(raw).map_err(Into::into), + None => Err(XRPLModelException::MissingField("result".to_string())), + } + } +} impl_try_from_response!(path_find, PathFind, PathFind); impl_try_from_response!(ping, Ping, Ping); impl_try_from_response!(random, Random, Random); -impl_try_from_response!(ripple_path_find, RipplePathFind, RipplePathFind); +impl<'a, 'b> TryFrom> for ripple_path_find::RipplePathFind<'b> +where + 'a: 'b, + 'b: 'a, +{ + type Error = XRPLModelException; + fn try_from(response: XRPLResponse<'a>) -> XRPLModelResult { + match response.result { + Some(XRPLResult::RipplePathFind(value)) => return Ok(value), + _ => {} + } + match response.raw_result { + Some(raw) => serde_json::from_value(raw).map_err(Into::into), + None => Err(XRPLModelException::MissingField("result".to_string())), + } + } +} impl<'a> TryFrom> for server_info::ServerInfo<'a> { type Error = XRPLModelException; @@ -588,6 +741,9 @@ impl<'a> TryFrom> for server_info::ServerInfo<'a> { match response.result { Some(result) => match result { XRPLResult::ServerInfo(value) => Ok(*value), + XRPLResult::Other(XRPLOtherResult(ref value)) => { + serde_json::from_value(value.clone()).map_err(Into::into) + } res => Err(XRPLResultException::UnexpectedResultType( "ServerInfo".to_string(), res.get_name(), @@ -616,7 +772,24 @@ impl<'a> TryFrom> for server_state::ServerState<'a> { } } impl_try_from_response!(submit, Submit, Submit); -impl_try_from_response!(submit_multisigned, SubmitMultisigned, SubmitMultisigned); +// SubmitMultisigned: may match Submit due to untagged enum ordering. +impl<'a, 'b> TryFrom> for submit_multisigned::SubmitMultisigned<'b> +where + 'a: 'b, + 'b: 'a, +{ + type Error = XRPLModelException; + fn try_from(response: XRPLResponse<'a>) -> XRPLModelResult { + match response.result { + Some(XRPLResult::SubmitMultisigned(value)) => return Ok(value), + _ => {} + } + match response.raw_result { + Some(raw) => serde_json::from_value(raw).map_err(Into::into), + None => Err(XRPLModelException::MissingField("result".to_string())), + } + } +} impl_try_from_response!(transaction_entry, TransactionEntry, TransactionEntry); impl_try_from_response!(subscribe, Subscribe, Subscribe); impl_try_from_response!(unsubscribe, Unsubscribe, Unsubscribe); @@ -645,12 +818,19 @@ impl<'a, 'de> Deserialize<'de> for XRPLResponse<'a> { forwarded: None, request: None, result: serde_json::from_value(map_as_value).map_err(serde::de::Error::custom)?, + raw_result: None, status: None, r#type: None, warning: None, warnings: None, }) } else { + // Preserve the raw result JSON so that TryFrom impls can + // re-deserialize when the untagged enum picks the wrong variant. + let raw_result = map.remove("result"); + let result = raw_result + .as_ref() + .and_then(|v| serde_json::from_value(v.clone()).ok()); Ok(XRPLResponse { id: map .remove("id") @@ -668,9 +848,8 @@ impl<'a, 'de> Deserialize<'de> for XRPLResponse<'a> { request: map .remove("request") .and_then(|v| serde_json::from_value(v).ok()), - result: map - .remove("result") - .and_then(|v| serde_json::from_value(v).ok()), + result, + raw_result, status: map .remove("status") .and_then(|v| serde_json::from_value(v).ok()), diff --git a/src/models/results/server_info.rs b/src/models/results/server_info.rs index 2c734951..4338b9a4 100644 --- a/src/models/results/server_info.rs +++ b/src/models/results/server_info.rs @@ -82,18 +82,31 @@ pub struct Info<'a> { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct ValidatorList<'a> { pub count: u32, - pub expiration: u32, + /// Expiration can be a number or "unknown" when the validator list + /// status is unknown (e.g. standalone mode). + pub expiration: Value, pub status: Cow<'a, str>, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct LastClose { - /// Time to reach consensus in seconds - pub converge_time_s: u64, + /// Time to reach consensus in seconds. + /// Can be a fractional value (e.g. 0.1), so we use serde_json::Number + /// to handle both integer and float representations. + pub converge_time_s: serde_json::Number, /// Number of trusted validators considered pub proposers: u32, } +impl Default for LastClose { + fn default() -> Self { + Self { + converge_time_s: serde_json::Number::from(0), + proposers: 0, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct Load<'a> { /// Information about job types and time spent @@ -202,7 +215,10 @@ mod tests { assert_eq!(result.info.hostid, Some("LEST".into())); assert_eq!(result.info.io_latency_ms, 1); assert_eq!(result.info.jq_trans_overflow, Some("0".into())); - assert_eq!(result.info.last_close.converge_time_s, 3); + assert_eq!( + result.info.last_close.converge_time_s, + serde_json::Number::from(3) + ); assert_eq!(result.info.last_close.proposers, 35); assert_eq!(result.info.load_factor, 1); assert_eq!(result.info.network_id, Some(10)); diff --git a/src/models/transactions/pseudo_transactions/enable_amendment.rs b/src/models/transactions/pseudo_transactions/enable_amendment.rs index c1f304e4..74877137 100644 --- a/src/models/transactions/pseudo_transactions/enable_amendment.rs +++ b/src/models/transactions/pseudo_transactions/enable_amendment.rs @@ -107,3 +107,54 @@ impl<'a> EnableAmendment<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + #[test] + fn test_serde_round_trip() { + let txn = EnableAmendment::new( + "rrrrrrrrrrrrrrrrrrrrrhoLvTp".into(), + None, + Some("10".into()), + Some(FlagCollection::new(vec![EnableAmendmentFlag::TfGotMajority])), + None, + None, + Some(1), + None, + None, + None, + "C1B8D934087225F509BEB5A8EC24447854713EE447D277F69545ABFA0E0FD490".into(), + 56865245, + ); + let serialized = serde_json::to_string(&txn).unwrap(); + let deserialized: EnableAmendment = serde_json::from_str(&serialized).unwrap(); + assert_eq!(txn, deserialized); + assert!(serialized.contains("\"TransactionType\":\"EnableAmendment\"")); + assert!(serialized.contains("\"Amendment\"")); + assert!(serialized.contains("\"LedgerSequence\":56865245")); + } + + #[test] + fn test_has_flag() { + let txn = EnableAmendment::new( + "rrrrrrrrrrrrrrrrrrrrrhoLvTp".into(), + None, + None, + Some(FlagCollection::new(vec![EnableAmendmentFlag::TfLostMajority])), + None, + None, + None, + None, + None, + None, + "ABCD".into(), + 1, + ); + assert!(txn.has_flag(&EnableAmendmentFlag::TfLostMajority)); + assert!(!txn.has_flag(&EnableAmendmentFlag::TfGotMajority)); + assert_eq!(txn.get_transaction_type(), &TransactionType::EnableAmendment); + } +} diff --git a/src/models/transactions/pseudo_transactions/set_fee.rs b/src/models/transactions/pseudo_transactions/set_fee.rs index 988ce794..8fcbc876 100644 --- a/src/models/transactions/pseudo_transactions/set_fee.rs +++ b/src/models/transactions/pseudo_transactions/set_fee.rs @@ -97,3 +97,36 @@ impl<'a> SetFee<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let txn = SetFee::new( + "rrrrrrrrrrrrrrrrrrrrrhoLvTp".into(), + None, + None, + None, + None, + None, + None, + None, + None, + "10".into(), + 10, + 20_000_000, + 5_000_000, + 56865245, + ); + let serialized = serde_json::to_string(&txn).unwrap(); + let deserialized: SetFee = serde_json::from_str(&serialized).unwrap(); + assert_eq!(txn, deserialized); + assert!(serialized.contains("\"TransactionType\":\"SetFee\"")); + assert!(serialized.contains("\"BaseFee\":\"10\"")); + assert!(serialized.contains("\"ReferenceFeeUnits\":10")); + assert!(serialized.contains("\"ReserveBase\":20000000")); + assert_eq!(txn.get_transaction_type(), &TransactionType::SetFee); + } +} diff --git a/src/models/transactions/pseudo_transactions/unl_modify.rs b/src/models/transactions/pseudo_transactions/unl_modify.rs index 59c5bc67..f6ef1078 100644 --- a/src/models/transactions/pseudo_transactions/unl_modify.rs +++ b/src/models/transactions/pseudo_transactions/unl_modify.rs @@ -102,3 +102,52 @@ impl<'a> UNLModify<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_serde_round_trip() { + let txn = UNLModify::new( + "rrrrrrrrrrrrrrrrrrrrrhoLvTp".into(), + None, + None, + None, + None, + None, + None, + None, + None, + 56865245, + UNLModifyDisabling::Enable, + "ED1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF".into(), + ); + let serialized = serde_json::to_string(&txn).unwrap(); + let deserialized: UNLModify = serde_json::from_str(&serialized).unwrap(); + assert_eq!(txn, deserialized); + assert!(serialized.contains("\"TransactionType\":\"UNLModify\"")); + assert!(serialized.contains("\"UnlmodifyDisabling\":1")); + assert_eq!(txn.get_transaction_type(), &TransactionType::UNLModify); + } + + #[test] + fn test_disabling_disable() { + let txn = UNLModify::new( + "rrrrrrrrrrrrrrrrrrrrrhoLvTp".into(), + None, + None, + None, + None, + None, + None, + None, + None, + 1, + UNLModifyDisabling::Disable, + "ED00".into(), + ); + let serialized = serde_json::to_string(&txn).unwrap(); + assert!(serialized.contains("\"UnlmodifyDisabling\":0")); + } +} diff --git a/src/models/transactions/xchain_add_claim_attestation.rs b/src/models/transactions/xchain_add_claim_attestation.rs index 81ad0a66..26eab09b 100644 --- a/src/models/transactions/xchain_add_claim_attestation.rs +++ b/src/models/transactions/xchain_add_claim_attestation.rs @@ -98,3 +98,81 @@ impl<'a> XChainAddClaimAttestation<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::amount::XRPAmount; + use crate::models::currency::XRP; + + fn xrp_bridge<'a>() -> XChainBridge<'a> { + XChainBridge { + locking_chain_door: "rMAXACCrp3Y8PpswXcg3bKggHX76V3F8M4".into(), + locking_chain_issue: XRP::new().into(), + issuing_chain_door: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + issuing_chain_issue: XRP::new().into(), + } + } + + #[test] + fn test_serde_round_trip() { + let txn = XChainAddClaimAttestation::new( + "rPV4mZjsXfH2HvUSPLNmqz1J8d3Lpv7tpe".into(), + None, + Some(XRPAmount::from("10")), + None, + None, + Some(1), + None, + None, + None, + Amount::XRPAmount(XRPAmount::from("10000")), + "rPV4mZjsXfH2HvUSPLNmqz1J8d3Lpv7tpe".into(), + "rPV4mZjsXfH2HvUSPLNmqz1J8d3Lpv7tpe".into(), + "rSrc111111111111111111111111111111".into(), + "ED1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF".into(), + "30440220ABCDEF".into(), + 1, + xrp_bridge(), + "13f".into(), + Some("rDest11111111111111111111111111111".into()), + ); + let serialized = serde_json::to_string(&txn).unwrap(); + let deserialized: XChainAddClaimAttestation = serde_json::from_str(&serialized).unwrap(); + let reserialized = serde_json::to_string(&deserialized).unwrap(); + assert_eq!(serialized, reserialized); + assert!(serialized.contains("\"TransactionType\":\"XChainAddClaimAttestation\"")); + assert!(serialized.contains("\"XChainBridge\"")); + assert!(serialized.contains("\"XChainClaimID\":\"13f\"")); + assert!(serialized.contains("\"WasLockingChainSend\":1")); + } + + #[test] + fn test_get_transaction_type() { + let txn = XChainAddClaimAttestation::new( + "rPV4mZjsXfH2HvUSPLNmqz1J8d3Lpv7tpe".into(), + None, + None, + None, + None, + None, + None, + None, + None, + Amount::XRPAmount(XRPAmount::from("10000")), + "rPV4mZjsXfH2HvUSPLNmqz1J8d3Lpv7tpe".into(), + "rPV4mZjsXfH2HvUSPLNmqz1J8d3Lpv7tpe".into(), + "rSrc111111111111111111111111111111".into(), + "ED00".into(), + "30".into(), + 0, + xrp_bridge(), + "1".into(), + None, + ); + assert_eq!( + txn.get_transaction_type(), + &TransactionType::XChainAddClaimAttestation + ); + } +} diff --git a/src/models/transactions/xchain_commit.rs b/src/models/transactions/xchain_commit.rs index 638f6d41..196e4cc0 100644 --- a/src/models/transactions/xchain_commit.rs +++ b/src/models/transactions/xchain_commit.rs @@ -123,3 +123,67 @@ mod test_serde { assert_eq!(actual, expected); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::currency::XRP; + use crate::models::transactions::Transaction; + + fn xrp_bridge<'a>() -> XChainBridge<'a> { + XChainBridge { + locking_chain_door: "rMAXACCrp3Y8PpswXcg3bKggHX76V3F8M4".into(), + locking_chain_issue: XRP::new().into(), + issuing_chain_door: "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh".into(), + issuing_chain_issue: XRP::new().into(), + } + } + + #[test] + fn test_constructor_round_trip() { + let txn = XChainCommit::new( + "rMTi57fNy2UkUb4RcdoUeJm7gjxVQvxzUo".into(), + None, + Some(XRPAmount::from("10")), + None, + None, + Some(1), + None, + None, + None, + Amount::XRPAmount(XRPAmount::from("10000")), + xrp_bridge(), + "13f".into(), + Some("rDest11111111111111111111111111111".into()), + ); + let serialized = serde_json::to_string(&txn).unwrap(); + let deserialized: XChainCommit = serde_json::from_str(&serialized).unwrap(); + let reserialized = serde_json::to_string(&deserialized).unwrap(); + assert_eq!(serialized, reserialized); + assert!(serialized.contains("\"TransactionType\":\"XChainCommit\"")); + assert!(serialized.contains("\"XChainBridge\"")); + assert!(serialized.contains("\"XChainClaimID\":\"13f\"")); + assert!(serialized.contains("\"OtherChainDestination\"")); + assert_eq!(txn.get_transaction_type(), &TransactionType::XChainCommit); + } + + #[test] + fn test_validate_currencies_ok() { + let txn = XChainCommit::new( + "rMTi57fNy2UkUb4RcdoUeJm7gjxVQvxzUo".into(), + None, + None, + None, + None, + None, + None, + None, + None, + Amount::XRPAmount(XRPAmount::from("10000")), + xrp_bridge(), + "1".into(), + None, + ); + assert!(txn.get_errors().is_ok()); + } +} diff --git a/src/utils/get_xchain_claim_id.rs b/src/utils/get_xchain_claim_id.rs index 333793d8..0e1bca0f 100644 --- a/src/utils/get_xchain_claim_id.rs +++ b/src/utils/get_xchain_claim_id.rs @@ -43,3 +43,99 @@ pub fn get_xchain_claim_id<'a: 'b, 'b>( } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn meta_with_xchain_claim() -> TransactionMetadata<'static> { + // Note: `Fields` derives `rename_all = "PascalCase"`, so + // `xchain_claim_id` deserializes from `XchainClaimId` (not the + // canonical XRPL casing `XChainClaimID`). Tracked separately. + let json = r#"{ + "AffectedNodes": [ + { + "CreatedNode": { + "LedgerEntryType": "XChainOwnedClaimID", + "LedgerIndex": "991ED60C316200D33B2EA3E56E505433394DBA7FF5E7ADE8C8850D02BEF1F53A", + "NewFields": { + "Account": "rPV4mZjsXfH2HvUSPLNmqz1J8d3Lpv7tpe", + "Flags": 0, + "XchainClaimId": "13f" + } + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }"#; + serde_json::from_str(json).unwrap() + } + + #[test] + fn test_get_xchain_claim_id_success() { + let meta = meta_with_xchain_claim(); + let id = get_xchain_claim_id(&meta).unwrap(); + assert_eq!(id, "13f"); + } + + #[test] + fn test_get_xchain_claim_id_no_xchain_node() { + let json = r#"{ + "AffectedNodes": [ + { + "CreatedNode": { + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "991ED60C316200D33B2EA3E56E505433394DBA7FF5E7ADE8C8850D02BEF1F53A", + "NewFields": { + "Account": "rHzKtpcB1KC1YuU4PBhk9m2abqrf2kZsfV", + "Flags": 0 + } + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }"#; + let meta: TransactionMetadata = serde_json::from_str(json).unwrap(); + let result = get_xchain_claim_id(&meta); + assert!(result.is_err()); + } + + #[test] + fn test_get_xchain_claim_id_empty_affected_nodes() { + let json = r#"{ + "AffectedNodes": [], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }"#; + let meta: TransactionMetadata = serde_json::from_str(json).unwrap(); + let result = get_xchain_claim_id(&meta); + assert!(result.is_err()); + } + + #[test] + fn test_get_xchain_claim_id_modified_node_only() { + // Modified node of XChainOwnedClaimID should not match - only created + // nodes are considered. + let json = r#"{ + "AffectedNodes": [ + { + "ModifiedNode": { + "LedgerEntryType": "XChainOwnedClaimID", + "LedgerIndex": "991ED60C316200D33B2EA3E56E505433394DBA7FF5E7ADE8C8850D02BEF1F53A", + "FinalFields": { + "Account": "rPV4mZjsXfH2HvUSPLNmqz1J8d3Lpv7tpe", + "Flags": 0 + } + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }"#; + let meta: TransactionMetadata = serde_json::from_str(json).unwrap(); + let result = get_xchain_claim_id(&meta); + assert!(result.is_err()); + } +} diff --git a/src/utils/str_conversion.rs b/src/utils/str_conversion.rs index 41d95b4c..42dd6333 100644 --- a/src/utils/str_conversion.rs +++ b/src/utils/str_conversion.rs @@ -20,3 +20,47 @@ pub fn hex_to_str<'a: 'b, 'b>(value: Cow<'a, str>) -> XRPLUtilsResult AmmPool { // Step 5: lp_wallet deposits 1000 XRP drops (TfSingleAsset) so pool has // enough XRP for a 500-drop single-asset withdraw in amm_withdraw tests. - // Mirrors xrpl.js setupAMMPool testWallet deposit. + // Adds liquidity so pool has enough XRP for single-asset withdraw tests. let mut deposit_tx = AMMDeposit::new( lp_wallet.classic_address.clone().into(), None, // account_txn_id diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 14093264..36c47b40 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -183,9 +183,8 @@ where } /// Look up the OfferSequence for the first escrow owned by `account`. -/// Mirrors xrpl.js: -/// const accountObjects = (await client.request({command:'account_objects', account})).result.account_objects -/// const sequence = (await client.request({command:'tx', transaction: accountObjects[0].PreviousTxnID})).result.tx_json.Sequence +/// Queries account_objects to find the escrow, then looks up its creating +/// transaction to extract the validated Sequence number. #[cfg(feature = "std")] pub async fn get_escrow_offer_sequence(account: &str) -> u32 { use xrpl::asynch::clients::XRPLAsyncClient; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index c9ea7a1d..e87606a0 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -6,6 +6,14 @@ ))] mod common; +#[cfg(all( + feature = "integration", + feature = "std", + feature = "json-rpc", + feature = "helpers" +))] +mod requests; + #[cfg(all( feature = "integration", feature = "std", diff --git a/tests/requests/account_channels.rs b/tests/requests/account_channels.rs new file mode 100644 index 00000000..a7f75896 --- /dev/null +++ b/tests/requests/account_channels.rs @@ -0,0 +1,48 @@ +// Scenarios: +// - base: send an account_channels request for a funded wallet and verify +// the response returns an empty channels list (no channels created) + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::requests::account_channels::AccountChannels; +use xrpl::models::requests::LedgerIndex; +use xrpl::models::results::account_channels::AccountChannels as AccountChannelsResult; + +#[tokio::test] +async fn test_account_channels_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let wallet = crate::common::generate_funded_wallet().await; + + let request = AccountChannels::new( + None, // id + wallet.classic_address.clone().into(), // account + None, // destination_account + None, // ledger_hash + Some(LedgerIndex::Str("validated".into())), // ledger_index + None, // limit + None, // marker + ); + + let response = client + .request(request.into()) + .await + .expect("account_channels request failed"); + + let result: AccountChannelsResult = response + .try_into() + .expect("failed to parse account_channels result"); + + // Verify account matches + assert_eq!(result.account.as_ref(), wallet.classic_address.as_str()); + // Verify channels is empty (no channels created) + assert!(result.channels.is_empty()); + // Verify validated + assert!(result.validated); + // Verify ledger_hash exists + assert!(result.ledger_hash.is_some()); + // Verify ledger_index is valid + assert!(result.ledger_index > 0); + }) + .await; +} diff --git a/tests/requests/account_currencies.rs b/tests/requests/account_currencies.rs new file mode 100644 index 00000000..7a9ae879 --- /dev/null +++ b/tests/requests/account_currencies.rs @@ -0,0 +1,45 @@ +// Scenarios: +// - base: send an account_currencies request for a funded wallet and verify +// the response returns empty currency lists (no trust lines created) + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::requests::account_currencies::AccountCurrencies; +use xrpl::models::requests::LedgerIndex; +use xrpl::models::results::account_currencies::AccountCurrencies as AccountCurrenciesResult; + +#[tokio::test] +async fn test_account_currencies_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let wallet = crate::common::generate_funded_wallet().await; + + let request = AccountCurrencies::new( + None, // id + wallet.classic_address.clone().into(), // account + None, // ledger_hash + Some(LedgerIndex::Str("validated".into())), // ledger_index + Some(true), // strict + ); + + let response = client + .request(request.into()) + .await + .expect("account_currencies request failed"); + + let result: AccountCurrenciesResult = response + .try_into() + .expect("failed to parse account_currencies result"); + + // Verify currencies are empty (no trust lines) + assert!(result.receive_currencies.is_empty()); + assert!(result.send_currencies.is_empty()); + // Verify validated + assert!(result.validated); + // Verify ledger_hash exists + assert!(result.ledger_hash.is_some()); + // Verify ledger_index is valid + assert!(result.ledger_index > 0); + }) + .await; +} diff --git a/tests/requests/account_info.rs b/tests/requests/account_info.rs new file mode 100644 index 00000000..dc858513 --- /dev/null +++ b/tests/requests/account_info.rs @@ -0,0 +1,55 @@ +// Scenarios: +// - base: send an account_info request for a funded wallet and verify the response + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::requests::account_info::AccountInfo; +use xrpl::models::requests::LedgerIndex; +use xrpl::models::results::account_info::AccountInfoVersionMap; + +#[tokio::test] +async fn test_account_info_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let wallet = crate::common::generate_funded_wallet().await; + + let request = AccountInfo::new( + None, // id + wallet.classic_address.clone().into(), // account + None, // ledger_hash + Some(LedgerIndex::Str("validated".into())), // ledger_index + Some(true), // strict + None, // queue + None, // signer_lists + ); + + let response = client + .request(request.into()) + .await + .expect("account_info request failed"); + + let result: AccountInfoVersionMap = response + .try_into() + .expect("failed to parse account_info result"); + + let account_data = result.get_account_root(); + + // Verify account matches + assert_eq!( + account_data.account.as_ref(), + wallet.classic_address.as_str() + ); + // Verify balance is 400 XRP (400000000 drops) + assert_eq!( + account_data.balance.as_ref().map(|b| b.0.as_ref()), + Some("400000000") + ); + // Verify owner count + assert_eq!(account_data.owner_count, 0); + // Verify sequence is a valid number + assert!(account_data.sequence > 0); + // Verify PreviousTxnID exists + assert!(!account_data.previous_txn_id.is_empty()); + }) + .await; +} diff --git a/tests/requests/account_lines.rs b/tests/requests/account_lines.rs new file mode 100644 index 00000000..dce7a921 --- /dev/null +++ b/tests/requests/account_lines.rs @@ -0,0 +1,45 @@ +// Scenarios: +// - base: send an account_lines request for a funded wallet and verify +// the response returns an empty lines list (no trust lines created) + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::requests::account_lines::AccountLines; +use xrpl::models::requests::LedgerIndex; +use xrpl::models::results::account_lines::AccountLines as AccountLinesResult; + +#[tokio::test] +async fn test_account_lines_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let wallet = crate::common::generate_funded_wallet().await; + + let request = AccountLines::new( + None, // id + wallet.classic_address.clone().into(), // account + None, // ledger_hash + Some(LedgerIndex::Str("validated".into())), // ledger_index + None, // limit + None, // peer + ); + + let response = client + .request(request.into()) + .await + .expect("account_lines request failed"); + + let result: AccountLinesResult = response + .try_into() + .expect("failed to parse account_lines result"); + + // Verify account matches + assert_eq!(result.account.as_ref(), wallet.classic_address.as_str()); + // Verify lines is empty (no trust lines created) + assert!(result.lines.is_empty()); + // Verify ledger_hash exists + assert!(result.ledger_hash.is_some()); + // Verify ledger_index is valid + assert!(result.ledger_index.unwrap() > 0); + }) + .await; +} diff --git a/tests/requests/account_objects.rs b/tests/requests/account_objects.rs new file mode 100644 index 00000000..9d1ce6ba --- /dev/null +++ b/tests/requests/account_objects.rs @@ -0,0 +1,47 @@ +// Scenarios: +// - base: send an account_objects request for a funded wallet and verify +// the response returns an empty account_objects list + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::requests::account_objects::AccountObjects; +use xrpl::models::requests::LedgerIndex; +use xrpl::models::results::account_objects::AccountObjects as AccountObjectsResult; + +#[tokio::test] +async fn test_account_objects_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let wallet = crate::common::generate_funded_wallet().await; + + let request = AccountObjects::new( + None, // id + wallet.classic_address.clone().into(), // account + None, // ledger_hash + Some(LedgerIndex::Str("validated".into())), // ledger_index + None, // type + None, // deletion_blockers_only + None, // limit + None, // marker + ); + + let response = client + .request(request.into()) + .await + .expect("account_objects request failed"); + + let result: AccountObjectsResult = response + .try_into() + .expect("failed to parse account_objects result"); + + // Verify account matches + assert_eq!(result.account.as_ref(), wallet.classic_address.as_str()); + // Verify account_objects is empty + assert!(result.account_objects.is_empty()); + // Verify ledger_hash exists + assert!(result.ledger_hash.is_some()); + // Verify ledger_index is valid + assert!(result.ledger_index.unwrap() > 0); + }) + .await; +} diff --git a/tests/requests/account_offers.rs b/tests/requests/account_offers.rs new file mode 100644 index 00000000..9c5811a1 --- /dev/null +++ b/tests/requests/account_offers.rs @@ -0,0 +1,43 @@ +// Scenarios: +// - base: send an account_offers request for a funded wallet and verify +// the response returns an empty offers list + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::requests::account_offers::AccountOffers; +use xrpl::models::results::account_offers::AccountOffers as AccountOffersResult; + +#[tokio::test] +async fn test_account_offers_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let wallet = crate::common::generate_funded_wallet().await; + + let request = AccountOffers::new( + None, // id + wallet.classic_address.clone().into(), // account + None, // ledger_hash + None, // ledger_index + None, // limit + Some(true), // strict + None, // marker + ); + + let response = client + .request(request.into()) + .await + .expect("account_offers request failed"); + + let result: AccountOffersResult = response + .try_into() + .expect("failed to parse account_offers result"); + + // Verify account matches + assert_eq!(result.account.as_ref(), wallet.classic_address.as_str()); + // Verify offers is empty + assert!(result.offers.is_empty()); + // Verify ledger_current_index exists (no specific ledger requested) + assert!(result.ledger_current_index.unwrap() > 0); + }) + .await; +} diff --git a/tests/requests/account_tx.rs b/tests/requests/account_tx.rs new file mode 100644 index 00000000..b695a135 --- /dev/null +++ b/tests/requests/account_tx.rs @@ -0,0 +1,82 @@ +// Scenarios: +// - base: send an account_tx request for a funded wallet and verify the +// response contains the funding transaction (Payment from genesis) + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::requests::account_tx::AccountTx; +use xrpl::models::requests::LedgerIndex; +use xrpl::models::results::account_tx::AccountTxVersionMap; + +#[tokio::test] +async fn test_account_tx_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let wallet = crate::common::generate_funded_wallet().await; + + let request = AccountTx::new( + None, // id + wallet.classic_address.clone().into(), // account + None, // ledger_hash + Some(LedgerIndex::Str("validated".into())), // ledger_index + None, // binary + None, // forward + None, // ledger_index_min + None, // ledger_index_max + None, // limit + None, // marker + ); + + let response = client + .request(request.into()) + .await + .expect("account_tx request failed"); + + let result: AccountTxVersionMap = response + .try_into() + .expect("failed to parse account_tx result"); + + // Standalone rippled returns API v1 format by default (tx object + // instead of tx_json), so we handle both variants. + match result { + AccountTxVersionMap::Default(tx_result) => { + // API v2 format + assert_eq!( + tx_result.base.account.as_ref(), + wallet.classic_address.as_str() + ); + assert!( + !tx_result.base.transactions.is_empty(), + "Expected at least one transaction" + ); + let first_tx = &tx_result.base.transactions[0]; + assert!(!first_tx.hash.is_empty()); + let tx_json = first_tx.tx_json.as_ref().expect("tx_json should exist"); + assert_eq!(tx_json["TransactionType"].as_str().unwrap(), "Payment"); + assert_eq!( + tx_json["Destination"].as_str().unwrap(), + wallet.classic_address.as_str() + ); + } + AccountTxVersionMap::V1(tx_result) => { + // API v1 format (standalone node default) + assert_eq!( + tx_result.base.account.as_ref(), + wallet.classic_address.as_str() + ); + assert!( + !tx_result.base.transactions.is_empty(), + "Expected at least one transaction" + ); + let first_tx = &tx_result.base.transactions[0]; + let tx = first_tx.tx.as_ref().expect("tx should exist"); + assert_eq!(tx["TransactionType"].as_str().unwrap(), "Payment"); + assert_eq!( + tx["Destination"].as_str().unwrap(), + wallet.classic_address.as_str() + ); + } + } + }) + .await; +} diff --git a/tests/requests/amm_info.rs b/tests/requests/amm_info.rs new file mode 100644 index 00000000..3f9c0305 --- /dev/null +++ b/tests/requests/amm_info.rs @@ -0,0 +1,43 @@ +// Scenarios: +// - base: set up an AMM pool and query its info using amm_info + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::{ + requests::amm_info::AMMInfo as AMMInfoRequest, + results::amm_info::AMMInfo as AMMInfoResult, + Currency, IssuedCurrency, XRP, +}; + +#[tokio::test] +async fn test_amm_info_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let pool = crate::common::amm::setup_amm_pool().await; + + let request = AMMInfoRequest::new( + None, // id + None, // amm_account + Some(Currency::XRP(XRP::new())), + Some(Currency::IssuedCurrency(IssuedCurrency::new( + "USD".into(), + pool.issuer_wallet.classic_address.clone().into(), + ))), + ); + + let response = client + .request(request.into()) + .await + .expect("amm_info request failed"); + + let result: AMMInfoResult = response + .try_into() + .expect("failed to parse amm_info result"); + + // Verify the AMM description has valid data + assert!(!result.amm.account.is_empty()); + assert_eq!(result.amm.trading_fee, 12); + }) + .await; +} + diff --git a/tests/requests/book_offers.rs b/tests/requests/book_offers.rs new file mode 100644 index 00000000..a52471ed --- /dev/null +++ b/tests/requests/book_offers.rs @@ -0,0 +1,45 @@ +// Scenarios: +// - base: send a book_offers request for XRP/USD and verify the response + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::requests::book_offers::BookOffers; +use xrpl::models::results::book_offers::BookOffers as BookOffersResult; +use xrpl::models::{Currency, IssuedCurrency, XRP}; + +#[tokio::test] +async fn test_book_offers_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let wallet = crate::common::generate_funded_wallet().await; + + let request = BookOffers::new( + None, // id + Currency::XRP(XRP::new()), // taker_gets + Currency::IssuedCurrency(IssuedCurrency::new( + // taker_pays + "USD".into(), + wallet.classic_address.clone().into(), + )), + None, // ledger_hash + None, // ledger_index + None, // limit + None, // taker + ); + + let response = client + .request(request.into()) + .await + .expect("book_offers request failed"); + + let result: BookOffersResult = response + .try_into() + .expect("failed to parse book_offers result"); + + // Verify offers is empty (no offers on the book) + assert!(result.offers.is_empty()); + // Verify ledger_current_index exists + assert!(result.ledger_current_index.unwrap() > 0); + }) + .await; +} diff --git a/tests/requests/channel_verify.rs b/tests/requests/channel_verify.rs new file mode 100644 index 00000000..d4637fcb --- /dev/null +++ b/tests/requests/channel_verify.rs @@ -0,0 +1,40 @@ +// Scenarios: +// - base: send a channel_verify request with hardcoded test data and verify the response + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::{ + requests::channel_verify::ChannelVerify as ChannelVerifyRequest, + results::channel_verify::ChannelVerify as ChannelVerifyResult, XRPAmount, +}; + +#[tokio::test] +async fn test_channel_verify_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + + let request = ChannelVerifyRequest::new( + None, // id + XRPAmount::from("1000000"), // amount + "5DB01B7FFED6B67E6B0414DED11E051D2EE2B7619CE0EAA6286D67A3A4D5BDB3".into(), // channel_id + "aB44YfzW24VDEJQ2UuLPV2PvqcPCSoLnL7y5M1EzhdW4LnK5xMS3".into(), // public_key + "304402204EF0AFB78AC23ED1C472E74F4299C0C21F1B21D07EFC0A3838A420F76D783A400220154FB11B6F54320666E4C36CA7F686C16A3A0456800BBC43746F34AF50290064".into(), // signature + ); + + let response = client + .request(request.into()) + .await + .expect("channel_verify request failed"); + + let result: ChannelVerifyResult = response + .try_into() + .expect("failed to parse channel_verify result"); + + // Verify that signature_verified is returned (the value depends on whether + // the channel actually exists, but the field should be present) + // With hardcoded test data, the signature may or may not verify + let _ = result.signature_verified; + }) + .await; +} + diff --git a/tests/requests/deposit_authorized.rs b/tests/requests/deposit_authorized.rs new file mode 100644 index 00000000..3f878c15 --- /dev/null +++ b/tests/requests/deposit_authorized.rs @@ -0,0 +1,49 @@ +// Scenarios: +// - base: send a deposit_authorized request between two funded wallets +// and verify that deposits are authorized by default + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::requests::deposit_authorize::DepositAuthorized; +use xrpl::models::results::deposit_authorize::DepositAuthorized as DepositAuthorizedResult; + +#[tokio::test] +async fn test_deposit_authorized_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let wallet1 = crate::common::generate_funded_wallet().await; + let wallet2 = crate::common::generate_funded_wallet().await; + + let request = DepositAuthorized::new( + None, // id + wallet2.classic_address.clone().into(), // destination_account + wallet1.classic_address.clone().into(), // source_account + None, // ledger_hash + None, // ledger_index + ); + + let response = client + .request(request.into()) + .await + .expect("deposit_authorized request failed"); + + let result: DepositAuthorizedResult = response + .try_into() + .expect("failed to parse deposit_authorized result"); + + // Verify deposit is authorized (default state) + assert!(result.deposit_authorized); + // Verify source and destination accounts match + assert_eq!( + result.source_account.as_ref(), + wallet1.classic_address.as_str() + ); + assert_eq!( + result.destination_account.as_ref(), + wallet2.classic_address.as_str() + ); + // Verify ledger_current_index exists + assert!(result.ledger_current_index.unwrap() > 0); + }) + .await; +} diff --git a/tests/requests/fee.rs b/tests/requests/fee.rs new file mode 100644 index 00000000..119cbf27 --- /dev/null +++ b/tests/requests/fee.rs @@ -0,0 +1,39 @@ +// Scenarios: +// - base: send a fee request and verify the response fields + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::{requests::fee::Fee as FeeRequest, results::fee::Fee as FeeResult}; + +#[tokio::test] +async fn test_fee_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + + let response = client + .request(FeeRequest::new(None).into()) + .await + .expect("fee request failed"); + + let result: FeeResult = response.try_into().expect("failed to parse fee result"); + + // Verify expected fields exist and have correct types + assert!(!result.current_ledger_size.is_empty()); + assert!(!result.current_queue_size.is_empty()); + assert!(!result.expected_ledger_size.is_empty()); + assert!(result.ledger_current_index > 0); + + // Verify drops fields + assert!(!result.drops.base_fee.0.is_empty()); + assert!(!result.drops.median_fee.0.is_empty()); + assert!(!result.drops.minimum_fee.0.is_empty()); + assert!(!result.drops.open_ledger_fee.0.is_empty()); + + // Verify levels fields + assert!(!result.levels.median_level.is_empty()); + assert!(!result.levels.minimum_level.is_empty()); + assert!(!result.levels.open_ledger_level.is_empty()); + assert!(!result.levels.reference_level.is_empty()); + }) + .await; +} diff --git a/tests/requests/gateway_balances.rs b/tests/requests/gateway_balances.rs new file mode 100644 index 00000000..e0c6d6fe --- /dev/null +++ b/tests/requests/gateway_balances.rs @@ -0,0 +1,45 @@ +// Scenarios: +// - base: send a gateway_balances request for a funded wallet and verify +// the response (no issued currencies, so balances/obligations are empty) + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::requests::gateway_balances::GatewayBalances; +use xrpl::models::requests::LedgerIndex; +use xrpl::models::results::gateway_balances::GatewayBalances as GatewayBalancesResult; + +#[tokio::test] +async fn test_gateway_balances_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let wallet = crate::common::generate_funded_wallet().await; + + let request = GatewayBalances::new( + None, // id + wallet.classic_address.clone().into(), // account + None, // hotwallet + None, // ledger_hash + Some(LedgerIndex::Str("validated".into())), // ledger_index + Some(true), // strict + ); + + let response = client + .request(request.into()) + .await + .expect("gateway_balances request failed"); + + let result: GatewayBalancesResult = response + .try_into() + .expect("failed to parse gateway_balances result"); + + // Verify account matches + assert_eq!(result.account.as_ref(), wallet.classic_address.as_str()); + // Verify ledger_hash exists + assert!(result.ledger_hash.is_some()); + // Verify ledger_index is valid + assert!(result.ledger_index.unwrap() > 0); + // Verify no obligations (no issued currencies) + assert!(result.obligations.is_none()); + }) + .await; +} diff --git a/tests/requests/ledger.rs b/tests/requests/ledger.rs new file mode 100644 index 00000000..1776e6cd --- /dev/null +++ b/tests/requests/ledger.rs @@ -0,0 +1,48 @@ +// Scenarios: +// - base: send a ledger request and verify the response fields + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::{ + requests::ledger::Ledger as LedgerRequest, results::ledger::Ledger as LedgerResult, +}; + +#[tokio::test] +async fn test_ledger_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + + let response = client + .request( + LedgerRequest::new( + None, + None, + None, + None, + None, + None, + Some("validated".into()), + None, + None, + None, + ) + .into(), + ) + .await + .expect("ledger request failed"); + + let result: LedgerResult = response.try_into().expect("failed to parse ledger result"); + + // Verify the response contains valid ledger data + assert!(!result.ledger_hash.is_empty()); + assert!(result.ledger_index > 0); + assert!(result.validated == Some(true)); + + // Verify ledger inner fields + assert!(!result.ledger.account_hash.is_empty()); + assert!(!result.ledger.ledger_hash.is_empty()); + assert!(result.ledger.close_time > 0); + assert!(result.ledger.closed); + }) + .await; +} diff --git a/tests/requests/ledger_closed.rs b/tests/requests/ledger_closed.rs new file mode 100644 index 00000000..b4b06bfd --- /dev/null +++ b/tests/requests/ledger_closed.rs @@ -0,0 +1,30 @@ +// Scenarios: +// - base: send a ledger_closed request and verify the response fields + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::{ + requests::ledger_closed::LedgerClosed as LedgerClosedRequest, + results::ledger_closed::LedgerClosed as LedgerClosedResult, +}; + +#[tokio::test] +async fn test_ledger_closed_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + + let response = client + .request(LedgerClosedRequest::new(None).into()) + .await + .expect("ledger_closed request failed"); + + let result: LedgerClosedResult = response + .try_into() + .expect("failed to parse ledger_closed result"); + + // Verify the response contains a valid ledger hash and index + assert!(!result.ledger_hash.is_empty()); + assert!(result.ledger_index > 0); + }) + .await; +} diff --git a/tests/requests/ledger_current.rs b/tests/requests/ledger_current.rs new file mode 100644 index 00000000..3178fde7 --- /dev/null +++ b/tests/requests/ledger_current.rs @@ -0,0 +1,29 @@ +// Scenarios: +// - base: send a ledger_current request and verify the response fields + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::{ + requests::ledger_current::LedgerCurrent as LedgerCurrentRequest, + results::ledger_current::LedgerCurrent as LedgerCurrentResult, +}; + +#[tokio::test] +async fn test_ledger_current_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + + let response = client + .request(LedgerCurrentRequest::new(None).into()) + .await + .expect("ledger_current request failed"); + + let result: LedgerCurrentResult = response + .try_into() + .expect("failed to parse ledger_current result"); + + // Verify the response contains a valid ledger current index + assert!(result.ledger_current_index > 0); + }) + .await; +} diff --git a/tests/requests/ledger_data.rs b/tests/requests/ledger_data.rs new file mode 100644 index 00000000..4098bbf8 --- /dev/null +++ b/tests/requests/ledger_data.rs @@ -0,0 +1,47 @@ +// Scenarios: +// - base: send a ledger_data request with binary=true and limit=5, verify the response + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::{ + requests::{ledger_data::LedgerData as LedgerDataRequest, LedgerIndex}, + results::ledger_data::LedgerData as LedgerDataResult, +}; + +#[tokio::test] +async fn test_ledger_data_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + + let request = LedgerDataRequest::new( + None, // id + Some(true), // binary + None, // ledger_hash + Some(LedgerIndex::Str("validated".into())), // ledger_index + Some(5), // limit + None, // marker + ); + + let response = client + .request(request.into()) + .await + .expect("ledger_data request failed"); + + let result: LedgerDataResult = response + .try_into() + .expect("failed to parse ledger_data result"); + + // Verify response fields + assert!(!result.ledger_hash.is_empty()); + assert!(result.ledger_index > 0); + assert_eq!(result.state.len(), 5); + + // Verify each state object has binary data and an index + for item in result.state.iter() { + assert!(item.data.is_some()); + assert!(!item.data.as_ref().unwrap().is_empty()); + assert!(!item.index.is_empty()); + } + }) + .await; +} diff --git a/tests/requests/ledger_entry.rs b/tests/requests/ledger_entry.rs new file mode 100644 index 00000000..7a3b32e2 --- /dev/null +++ b/tests/requests/ledger_entry.rs @@ -0,0 +1,70 @@ +// Scenarios: +// - base: get an entry index from ledger_data, then query ledger_entry with that index + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::{ + requests::{ledger_data::LedgerData as LedgerDataRequest, LedgerIndex}, + results::ledger_data::LedgerData as LedgerDataResult, +}; + +#[tokio::test] +async fn test_ledger_entry_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + + // First, get a valid entry index from ledger_data + let data_request = LedgerDataRequest::new( + None, // id + None, // binary + None, // ledger_hash + Some(LedgerIndex::Str("validated".into())), // ledger_index + Some(1), // limit + None, // marker + ); + + let data_response = client + .request(data_request.into()) + .await + .expect("ledger_data request failed"); + + let data_result: LedgerDataResult = data_response + .try_into() + .expect("failed to parse ledger_data result"); + + let entry_index = data_result.state[0].index.clone(); + + // Now query ledger_entry with that index + let entry_request = xrpl::models::requests::ledger_entry::LedgerEntry::new( + None, // id + None, // account_root + None, // binary + None, // check + None, // deposit_preauth + None, // directory + None, // escrow + Some(entry_index.clone()), // index + None, // ledger_hash + None, // ledger_index + None, // offer + None, // payment_channel + None, // ripple_state + None, // ticket + ); + + let entry_response = client + .request(entry_request.into()) + .await + .expect("failed ledger_entry request"); + + let entry_result: xrpl::models::results::ledger_entry::LedgerEntry = entry_response + .try_into() + .expect("failed to parse ledger_entry result"); + + // Verify the returned index matches what we requested + assert_eq!(entry_result.index.as_ref(), entry_index.as_ref()); + // Verify the node is present (non-binary mode) + assert!(entry_result.node.is_some()); + }) + .await; +} diff --git a/tests/requests/mod.rs b/tests/requests/mod.rs new file mode 100644 index 00000000..5c00d833 --- /dev/null +++ b/tests/requests/mod.rs @@ -0,0 +1,27 @@ +mod account_channels; +mod account_currencies; +mod account_info; +mod account_lines; +mod account_objects; +mod account_offers; +mod account_tx; +mod amm_info; +mod book_offers; +mod channel_verify; +mod deposit_authorized; +mod fee; +mod gateway_balances; +mod ledger; +mod ledger_closed; +mod ledger_current; +mod ledger_data; +mod ledger_entry; +mod no_ripple_check; +mod ping; +mod random; +mod ripple_path_find; +mod server_info; +mod server_state; +mod submit; +mod submit_multisigned; +mod tx; diff --git a/tests/requests/no_ripple_check.rs b/tests/requests/no_ripple_check.rs new file mode 100644 index 00000000..7d22e009 --- /dev/null +++ b/tests/requests/no_ripple_check.rs @@ -0,0 +1,49 @@ +// Scenarios: +// - base: send a noripple_check request for a funded wallet with role=gateway +// and transactions=true, verify problems and transactions arrays + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::{ + requests::no_ripple_check::{NoRippleCheck as NoRippleCheckRequest, NoRippleCheckRole}, + requests::LedgerIndex, + results::no_ripple_check::NoRippleCheck as NoRippleCheckResult, +}; + +#[tokio::test] +async fn test_no_ripple_check_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let wallet = crate::common::generate_funded_wallet().await; + + let request = NoRippleCheckRequest::new( + None, // id + wallet.classic_address.clone().into(), // account + NoRippleCheckRole::Gateway, // role + None, // ledger_hash + Some(LedgerIndex::Str("current".into())), // ledger_index + None, // limit + Some(true), // transactions + ); + + let response = client + .request(request.into()) + .await + .expect("noripple_check request failed"); + + let result: NoRippleCheckResult = response + .try_into() + .expect("failed to parse noripple_check result"); + + // A newly funded account with gateway role should have at least one problem + // (the default ripple flag recommendation) + assert!(!result.problems.is_empty()); + assert!(result.problems[0].contains("default ripple")); + + // With transactions=true, we should get suggested fix transactions + assert!(result.transactions.is_some()); + let transactions = result.transactions.unwrap(); + assert!(!transactions.is_empty()); + }) + .await; +} diff --git a/tests/requests/ping.rs b/tests/requests/ping.rs new file mode 100644 index 00000000..cbf4e2a5 --- /dev/null +++ b/tests/requests/ping.rs @@ -0,0 +1,23 @@ +// Scenarios: +// - base: send a ping request and verify the response is successful + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::requests::ping::Ping as PingRequest; + +#[tokio::test] +async fn test_ping_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + + let response = client + .request(PingRequest::new(None).into()) + .await + .expect("ping request failed"); + + // Ping response is minimal — verify the response is successful + assert!(response.is_success()); + assert!(response.error.is_none()); + }) + .await; +} diff --git a/tests/requests/random.rs b/tests/requests/random.rs new file mode 100644 index 00000000..e48c7d76 --- /dev/null +++ b/tests/requests/random.rs @@ -0,0 +1,30 @@ +// Scenarios: +// - base: send a random request and verify it returns a 64-character hex string + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::{ + requests::random::Random as RandomRequest, results::random::Random as RandomResult, +}; + +#[tokio::test] +async fn test_random_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + + let response = client + .request(RandomRequest::new(None).into()) + .await + .expect("random request failed"); + + let result: RandomResult = response + .try_into() + .expect("failed to parse random result"); + + // Verify the random string is a 64-character hex value + assert_eq!(result.random.len(), 64); + assert!(result.random.chars().all(|c| c.is_ascii_hexdigit())); + }) + .await; +} + diff --git a/tests/requests/ripple_path_find.rs b/tests/requests/ripple_path_find.rs new file mode 100644 index 00000000..abd0fa17 --- /dev/null +++ b/tests/requests/ripple_path_find.rs @@ -0,0 +1,48 @@ +// Scenarios: +// - base: send a ripple_path_find request between two funded wallets and +// verify destination_account and destination_currencies + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::{ + requests::ripple_path_find::RipplePathFind as RipplePathFindRequest, + results::ripple_path_find::RipplePathFind as RipplePathFindResult, Amount, +}; + +#[tokio::test] +async fn test_ripple_path_find_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let wallet1 = crate::common::generate_funded_wallet().await; + let wallet2 = crate::common::generate_funded_wallet().await; + + let request = RipplePathFindRequest::new( + None, // id + wallet2.classic_address.clone().into(), // destination_account + Amount::XRPAmount("100".into()), // destination_amount (XRP drops) + wallet1.classic_address.clone().into(), // source_account + None, // ledger_hash + None, // ledger_index + None, // send_max + None, // source_currencies + ); + + let response = client + .request(request.into()) + .await + .expect("ripple_path_find request failed"); + + let result: RipplePathFindResult = response + .try_into() + .expect("failed to parse ripple_path_find result"); + + // Verify the destination account matches + assert_eq!( + result.destination_account.as_ref(), + wallet2.classic_address.as_str() + ); + // Verify destination_currencies is not empty (should at least contain XRP) + assert!(!result.destination_currencies.is_empty()); + }) + .await; +} diff --git a/tests/requests/server_info.rs b/tests/requests/server_info.rs new file mode 100644 index 00000000..00a1c38f --- /dev/null +++ b/tests/requests/server_info.rs @@ -0,0 +1,36 @@ +// Scenarios: +// - base: send a server_info request and verify the response fields + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::{ + requests::server_info::ServerInfo as ServerInfoRequest, + results::server_info::ServerInfo as ServerInfoResult, +}; + +#[tokio::test] +async fn test_server_info_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + + let response = client + .request(ServerInfoRequest::new(None).into()) + .await + .expect("server_info request failed"); + + let result: ServerInfoResult = response + .try_into() + .expect("failed to parse server_info result"); + + // Verify essential server_info fields + assert!(!result.info.build_version.is_empty()); + assert!(!result.info.complete_ledgers.is_empty()); + assert!(result.info.load_factor > 0); + + // Verify validated_ledger is present (standalone always has one) + assert!(result.info.validated_ledger.is_some()); + let validated = result.info.validated_ledger.unwrap(); + assert!(validated.seq > 0); + }) + .await; +} diff --git a/tests/requests/server_state.rs b/tests/requests/server_state.rs new file mode 100644 index 00000000..faf7b7ff --- /dev/null +++ b/tests/requests/server_state.rs @@ -0,0 +1,34 @@ +// Scenarios: +// - base: send a server_state request and verify the response fields + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::{ + requests::server_state::ServerState as ServerStateRequest, + results::server_state::ServerState as ServerStateResult, +}; + +#[tokio::test] +async fn test_server_state_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + + let response = client + .request(ServerStateRequest::new(None).into()) + .await + .expect("server_state request failed"); + + let result: ServerStateResult = response + .try_into() + .expect("failed to parse server_state result"); + + // Verify essential server_state fields + assert!(!result.state.build_version.is_empty()); + + // Verify validated_ledger is present (standalone always has one) + assert!(result.state.validated_ledger.is_some()); + let validated = result.state.validated_ledger.unwrap(); + assert!(validated.seq > 0); + }) + .await; +} diff --git a/tests/requests/submit.rs b/tests/requests/submit.rs new file mode 100644 index 00000000..0d9ef21c --- /dev/null +++ b/tests/requests/submit.rs @@ -0,0 +1,62 @@ +// Scenarios: +// - base: sign a transaction, encode it, submit via the submit request, +// and verify tesSUCCESS + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::core::binarycodec::encode; +use xrpl::models::requests::submit::Submit as SubmitRequest; +use xrpl::models::results::submit::Submit as SubmitResult; +use xrpl::models::transactions::payment::Payment; +use xrpl::models::{Amount, XRPAmount}; + +#[tokio::test] +async fn test_submit_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let wallet = crate::common::generate_funded_wallet().await; + let destination = crate::common::generate_funded_wallet().await; + + // Build and sign a payment + let mut payment = Payment::new( + wallet.classic_address.clone().into(), + None, // account_txn_id + None, // fee + None, // flags + None, // last_ledger_sequence + None, // memos + None, // sequence + None, // signers + None, // source_tag + None, // ticket_sequence + Amount::XRPAmount(XRPAmount::from("1000000")), + destination.classic_address.clone().into(), + None, // destination_tag + None, // invoice_id + None, // paths + None, // send_max + None, // deliver_min + ); + + // Autofill sequence, fee, etc. + xrpl::asynch::transaction::autofill_and_sign(&mut payment, client, &wallet, true) + .await + .expect("autofill_and_sign failed"); + + // Encode to blob and submit + let tx_blob = encode(&payment).expect("encode failed"); + let request = SubmitRequest::new(None, tx_blob.into(), None); + + let response = client + .request(request.into()) + .await + .expect("submit request failed"); + + let result: SubmitResult = response.try_into().expect("failed to parse submit result"); + + assert_eq!(result.engine_result.as_ref(), "tesSUCCESS"); + assert!(!result.tx_blob.is_empty()); + assert!(result.tx_json.is_object()); + }) + .await; +} diff --git a/tests/requests/submit_multisigned.rs b/tests/requests/submit_multisigned.rs new file mode 100644 index 00000000..56b7450b --- /dev/null +++ b/tests/requests/submit_multisigned.rs @@ -0,0 +1,104 @@ +// Scenarios: +// - base: set up a SignerList on an account, multisign a transaction with +// two signers, submit via submit_multisigned, and verify tesSUCCESS + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::asynch::transaction::{autofill, sign}; +use xrpl::models::requests::submit_multisigned::SubmitMultisigned as SubmitMultisignedRequest; +use xrpl::models::results::submit_multisigned::SubmitMultisigned as SubmitMultisignedResult; +use xrpl::models::transactions::account_set::AccountSet; +use xrpl::models::transactions::signer_list_set::{SignerEntry, SignerListSet}; + +#[tokio::test] +async fn test_submit_multisigned_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let main_wallet = crate::common::generate_funded_wallet().await; + let signer1 = crate::common::generate_funded_wallet().await; + let signer2 = crate::common::generate_funded_wallet().await; + + // Step 1: Set up a SignerList on the main account (quorum=2, each weight=1) + let mut signer_list_tx = SignerListSet::new( + main_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 + 2, // signer_quorum + Some(vec![ + SignerEntry::new(signer1.classic_address.clone(), 1), + SignerEntry::new(signer2.classic_address.clone(), 1), + ]), + ); + crate::common::test_transaction(&mut signer_list_tx, &main_wallet).await; + + // Step 2: Build the transaction to be multisigned (AccountSet to set a domain) + let mut tx = AccountSet::new( + main_wallet.classic_address.clone().into(), + None, // account_txn_id + None, // fee + None, // flags + None, // last_ledger_sequence + None, // memos + None, // sequence + None, // signers + None, // source_tag + None, // ticket_sequence + None, // clear_flag + Some("6578616d706c652e636f6d".into()), // domain = "example.com" in hex + None, // email_hash + None, // message_key + None, // set_flag + None, // transfer_rate + None, // tick_size + None, // nftoken_minter + ); + + // Autofill without signing + autofill(&mut tx, client, None) + .await + .expect("autofill failed"); + + // Set fee high enough for multisig (n+1 signatures * base_fee) + // For 2 signers: 3 * 10 = 30 drops minimum + tx.common_fields.fee = Some("30000".into()); + + // Step 3: Multisign with both signers + let mut tx_signer1 = tx.clone(); + let mut tx_signer2 = tx.clone(); + + sign(&mut tx_signer1, &signer1, true).expect("multisign signer1 failed"); + sign(&mut tx_signer2, &signer2, true).expect("multisign signer2 failed"); + + // Combine signers + let signers1 = tx_signer1.common_fields.signers.unwrap_or_default(); + let signers2 = tx_signer2.common_fields.signers.unwrap_or_default(); + let mut combined_signers = signers1; + combined_signers.extend(signers2); + + tx.common_fields.signing_pub_key = Some("".into()); + tx.common_fields.signers = Some(combined_signers); + + // Step 4: Serialize and submit as multisigned + let tx_json = serde_json::to_value(&tx).expect("serialize tx failed"); + let request = SubmitMultisignedRequest::new(None, tx_json, None); + + let response = client + .request(request.into()) + .await + .expect("submit_multisigned request failed"); + + let result: SubmitMultisignedResult = response + .try_into() + .expect("failed to parse submit_multisigned result"); + + assert_eq!(result.engine_result.as_ref(), "tesSUCCESS"); + assert!(!result.tx_blob.is_empty()); + }) + .await; +} diff --git a/tests/requests/tx.rs b/tests/requests/tx.rs new file mode 100644 index 00000000..e8d75027 --- /dev/null +++ b/tests/requests/tx.rs @@ -0,0 +1,87 @@ +// Scenarios: +// - base: submit a payment transaction, then query it by hash using the tx request + +use crate::common::with_blockchain_lock; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::asynch::transaction::sign_and_submit; +use xrpl::models::requests::tx::Tx as TxRequest; +use xrpl::models::results::tx::TxVersionMap; +use xrpl::models::transactions::payment::Payment; +use xrpl::models::{Amount, XRPAmount}; + +#[tokio::test] +async fn test_tx_base() { + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let wallet = crate::common::generate_funded_wallet().await; + let destination = crate::common::generate_funded_wallet().await; + + // Submit a payment so we have a transaction to look up + let mut payment = Payment::new( + wallet.classic_address.clone().into(), + None, // account_txn_id + None, // fee + None, // flags + None, // last_ledger_sequence + None, // memos + None, // sequence + None, // signers + None, // source_tag + None, // ticket_sequence + Amount::XRPAmount(XRPAmount::from("1000000")), + destination.classic_address.clone().into(), + None, // destination_tag + None, // invoice_id + None, // paths + None, // send_max + None, // deliver_min + ); + + let submit_result = sign_and_submit(&mut payment, client, &wallet, true, true) + .await + .expect("sign_and_submit failed"); + assert_eq!(submit_result.engine_result.as_ref(), "tesSUCCESS"); + + // Extract the hash from the submit result + let tx_hash = submit_result + .tx_json + .get("hash") + .expect("submit result should contain hash") + .as_str() + .expect("hash should be a string"); + + // Advance the ledger so the transaction is validated + crate::common::ledger_accept().await; + + // Query the transaction by hash + let request = TxRequest::new( + None, // id + None, // binary + None, // max_ledger + None, // min_ledger + Some(tx_hash.to_string().into()), // transaction + ); + + let response = client + .request(request.into()) + .await + .expect("tx request failed"); + + let result: TxVersionMap = response + .try_into() + .expect("failed to parse tx result"); + + // Verify the hash matches what we submitted + match &result { + TxVersionMap::Default(tx) => { + assert_eq!(tx.base.hash.as_ref(), tx_hash); + assert!(tx.base.validated.unwrap_or(false)); + } + TxVersionMap::V1(tx) => { + assert_eq!(tx.base.hash.as_ref(), tx_hash); + } + } + }) + .await; +} + diff --git a/tests/transactions/account_delete.rs b/tests/transactions/account_delete.rs index 508eb2d1..e7e912af 100644 --- a/tests/transactions/account_delete.rs +++ b/tests/transactions/account_delete.rs @@ -1,12 +1,10 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/accountDelete.test.ts -// // Scenarios: // - base: submit AccountDelete and expect tecTOO_SOON // // NOTE: AccountDelete requires the account's sequence number to be at least 256 lower than the // current ledger index. A freshly funded account never satisfies this condition on testnet, so -// this test asserts tecTOO_SOON rather than tesSUCCESS (matching xrpl.js behavior — it only -// verifies the transaction is accepted by the node, not that the deletion succeeds). +// this test asserts tecTOO_SOON rather than tesSUCCESS (it only verifies the transaction is +// accepted by the node, not that the deletion succeeds). // // On Docker standalone mode, call ledger_accept() 256 times before submitting to satisfy the // condition and assert tesSUCCESS instead. diff --git a/tests/transactions/account_set.rs b/tests/transactions/account_set.rs index 4c377cc1..1e6782ca 100644 --- a/tests/transactions/account_set.rs +++ b/tests/transactions/account_set.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/accountSet.test.ts -// // Scenarios: // - base: set domain field with hex-encoded value // - with_memo: attach a memo to the transaction diff --git a/tests/transactions/amm_bid.rs b/tests/transactions/amm_bid.rs index 49ee78f3..6ff494e5 100644 --- a/tests/transactions/amm_bid.rs +++ b/tests/transactions/amm_bid.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/ammBid.test.ts -// // Scenarios: // - base: LP holder bids for the AMM's auction slot (no BidMin/BidMax/AuthAccounts) // diff --git a/tests/transactions/amm_create.rs b/tests/transactions/amm_create.rs index 140e72a3..095664c5 100644 --- a/tests/transactions/amm_create.rs +++ b/tests/transactions/amm_create.rs @@ -1,9 +1,7 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/ammCreate.test.ts -// // Scenarios: // - base: create an XRP/USD AMM pool (250 drops / 250 USD, trading_fee = 12) // -// Setup (mirrors createAMMPool in xrpl.js/utils.ts): +// Setup: // 1. issuerWallet AccountSet — enable DefaultRipple // 2. lpWallet TrustSet — trust issuer for 1000 USD (tfClearNoRipple) // 3. issuerWallet Payment — send 500 USD to lpWallet diff --git a/tests/transactions/amm_delete.rs b/tests/transactions/amm_delete.rs index 82f675e9..5090a6fd 100644 --- a/tests/transactions/amm_delete.rs +++ b/tests/transactions/amm_delete.rs @@ -1,4 +1,4 @@ -// xrpl.js reference: no dedicated test file — AMMDelete is a cleanup operation +// AMMDelete is a cleanup operation // for AMMs that could not be fully deleted by AMMWithdraw due to too many trust // lines (> ~512 LP token holders). In the simple 2-trust-line setup used here, // AMMWithdraw TfWithdrawAll auto-deletes the AMM in a single transaction, so diff --git a/tests/transactions/amm_deposit.rs b/tests/transactions/amm_deposit.rs index a3d8c437..bce9b979 100644 --- a/tests/transactions/amm_deposit.rs +++ b/tests/transactions/amm_deposit.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/ammDeposit.test.ts -// // Scenarios: // - single_asset: deposit 1000 XRP drops into an XRP/USD pool (TfSingleAsset flag) // diff --git a/tests/transactions/amm_vote.rs b/tests/transactions/amm_vote.rs index c8cb2090..9cb1e5b2 100644 --- a/tests/transactions/amm_vote.rs +++ b/tests/transactions/amm_vote.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/ammVote.test.ts -// // Scenarios: // - base: LP holder votes to change trading_fee to 150 (per 100_000) // diff --git a/tests/transactions/amm_withdraw.rs b/tests/transactions/amm_withdraw.rs index 83092c52..0719a311 100644 --- a/tests/transactions/amm_withdraw.rs +++ b/tests/transactions/amm_withdraw.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/ammWithdraw.test.ts -// // Scenarios: // - single_asset: withdraw 500 XRP drops from an XRP/USD pool (TfSingleAsset flag) // diff --git a/tests/transactions/check_cancel.rs b/tests/transactions/check_cancel.rs index b5197be1..33c00035 100644 --- a/tests/transactions/check_cancel.rs +++ b/tests/transactions/check_cancel.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/checkCancel.test.ts -// // Scenarios: // - base: create a Check for 50 drops, then cancel it (creator cancels their own check) diff --git a/tests/transactions/check_cash.rs b/tests/transactions/check_cash.rs index 50247ecf..f8bce789 100644 --- a/tests/transactions/check_cash.rs +++ b/tests/transactions/check_cash.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/checkCash.test.ts -// // Scenarios: // - base: create a Check for 500 drops, then cash it for the exact amount diff --git a/tests/transactions/check_create.rs b/tests/transactions/check_create.rs index b712ad25..02d1a55b 100644 --- a/tests/transactions/check_create.rs +++ b/tests/transactions/check_create.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/checkCreate.test.ts -// // Scenarios: // - base: create a Check for 50 drops and verify one check object exists on the ledger diff --git a/tests/transactions/deposit_preauth.rs b/tests/transactions/deposit_preauth.rs index 6904a488..8632a664 100644 --- a/tests/transactions/deposit_preauth.rs +++ b/tests/transactions/deposit_preauth.rs @@ -1,11 +1,8 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/depositPreauth.test.ts -// // Scenarios: // - base: authorize a second account to send payments to a deposit-auth-enabled account // -// NOTE: The AuthorizeCredentials / UnauthorizeCredentials scenarios in xrpl.js require the -// Credentials amendment which is not yet enabled on the public testnet. Those variants are -// deferred until Docker standalone mode. +// NOTE: The AuthorizeCredentials / UnauthorizeCredentials scenarios require the +// Credentials amendment which is not yet enabled. Those variants are deferred. use crate::common::{generate_funded_wallet, test_transaction, with_blockchain_lock}; use xrpl::models::transactions::deposit_preauth::DepositPreauth; diff --git a/tests/transactions/escrow_cancel.rs b/tests/transactions/escrow_cancel.rs index 38b8dc94..05067bdf 100644 --- a/tests/transactions/escrow_cancel.rs +++ b/tests/transactions/escrow_cancel.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/escrowCancel.test.ts -// // Scenarios: // - base: create a time-locked XRP escrow then cancel it once CancelAfter has passed // @@ -8,7 +6,6 @@ // 1. Queries account_objects to confirm the escrow exists on-chain // 2. Looks up the creating tx to get the validated Sequence (OfferSequence) // 3. Waits for close_time >= CancelAfter, then one more ledger_accept -// This mirrors the xrpl.js pattern exactly (account_objects → tx lookup). // An escrow can only be cancelled after CancelAfter passes. use crate::common::{ @@ -49,13 +46,12 @@ async fn test_escrow_cancel_base() { // test_transaction signs, submits, asserts tesSUCCESS, and calls ledger_accept. test_transaction(&mut create_tx, &wallet).await; - // Mirroring xrpl.js: look up the validated Sequence via account_objects → tx query + // Look up the validated Sequence via account_objects → tx query // instead of reading the autofilled value from the tx struct. This confirms the // escrow actually exists on-chain before we try to cancel it. let offer_sequence = get_escrow_offer_sequence(&wallet.classic_address).await; - // Wait for the validated ledger close_time to reach CancelAfter (mirrors - // xrpl.js waitForAndForceProgressLedgerTime(CLOSE_TIME + 3)). + // Wait for the validated ledger close_time to reach CancelAfter. wait_for_ledger_close_time(cancel_after as u64).await; // rippled validates a cancel using the *previous* ledger's close_time, // so one more ledger_accept ensures that previous close_time > CancelAfter. diff --git a/tests/transactions/escrow_create.rs b/tests/transactions/escrow_create.rs index 7a5a7d01..02021b07 100644 --- a/tests/transactions/escrow_create.rs +++ b/tests/transactions/escrow_create.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/escrowCreate.test.ts -// // Scenarios: // - base: create a time-locked XRP escrow (FinishAfter = close_time + 2) and verify tesSUCCESS // diff --git a/tests/transactions/escrow_finish.rs b/tests/transactions/escrow_finish.rs index 413776b3..ae2a3db6 100644 --- a/tests/transactions/escrow_finish.rs +++ b/tests/transactions/escrow_finish.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/escrowFinish.test.ts -// // Scenarios: // - base: create a time-locked XRP escrow then finish it once FinishAfter has passed // @@ -7,7 +5,6 @@ // 1. Queries account_objects to confirm the escrow exists on-chain // 2. Looks up the creating tx to get the validated Sequence (OfferSequence) // 3. Waits for close_time >= FinishAfter, then one more ledger_accept -// This mirrors the xrpl.js pattern exactly (account_objects → tx lookup). use crate::common::{ generate_funded_wallet, get_escrow_offer_sequence, get_ledger_close_time, ledger_accept, @@ -46,13 +43,12 @@ async fn test_escrow_finish_base() { // test_transaction signs, submits, asserts tesSUCCESS, and calls ledger_accept. test_transaction(&mut create_tx, &wallet).await; - // Mirroring xrpl.js: look up the validated Sequence via account_objects → tx query + // Look up the validated Sequence via account_objects → tx query // instead of reading the autofilled value from the tx struct. This confirms the // escrow actually exists on-chain before we try to finish it. let offer_sequence = get_escrow_offer_sequence(&wallet.classic_address).await; - // Wait for the validated ledger close_time to reach FinishAfter (mirrors - // xrpl.js waitForAndForceProgressLedgerTime(CLOSE_TIME + 2)). + // Wait for the validated ledger close_time to reach FinishAfter. wait_for_ledger_close_time(finish_after as u64).await; // rippled validates a finish using the *previous* ledger's close_time, // so one more ledger_accept ensures that previous close_time > FinishAfter. diff --git a/tests/transactions/nftoken_accept_offer.rs b/tests/transactions/nftoken_accept_offer.rs index de0edc48..7271d534 100644 --- a/tests/transactions/nftoken_accept_offer.rs +++ b/tests/transactions/nftoken_accept_offer.rs @@ -1,6 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/nftokenMint.test.ts -// (xrpl.js covers NFTokenAcceptOffer indirectly via the "test with Amount" scenario in nftokenMint) -// // Scenarios: // - sell_offer: seller mints a transferable NFT, creates a sell offer, buyer accepts it diff --git a/tests/transactions/nftoken_burn.rs b/tests/transactions/nftoken_burn.rs index 446d2e75..d7bfb9b8 100644 --- a/tests/transactions/nftoken_burn.rs +++ b/tests/transactions/nftoken_burn.rs @@ -1,6 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/nftokenMint.test.ts -// (xrpl.js does not have a dedicated NFTokenBurn test file) -// // Scenarios: // - base: mint an NFT then burn it diff --git a/tests/transactions/nftoken_cancel_offer.rs b/tests/transactions/nftoken_cancel_offer.rs index b79aef32..9ffef038 100644 --- a/tests/transactions/nftoken_cancel_offer.rs +++ b/tests/transactions/nftoken_cancel_offer.rs @@ -1,6 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/nftokenMint.test.ts -// (xrpl.js does not have a dedicated NFTokenCancelOffer test file) -// // Scenarios: // - base: mint an NFT, create a sell offer, then cancel it diff --git a/tests/transactions/nftoken_create_offer.rs b/tests/transactions/nftoken_create_offer.rs index 057d5cd6..dc41871f 100644 --- a/tests/transactions/nftoken_create_offer.rs +++ b/tests/transactions/nftoken_create_offer.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/nftokenMint.test.ts -// // Scenarios: // - sell_offer: mint an NFT then create a sell offer for it diff --git a/tests/transactions/nftoken_mint.rs b/tests/transactions/nftoken_mint.rs index 08879ce9..1873058c 100644 --- a/tests/transactions/nftoken_mint.rs +++ b/tests/transactions/nftoken_mint.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/nftokenMint.test.ts -// // Scenarios: // - base: mint an NFT with a URI diff --git a/tests/transactions/offer_cancel.rs b/tests/transactions/offer_cancel.rs index 051b4bd4..0c7848db 100644 --- a/tests/transactions/offer_cancel.rs +++ b/tests/transactions/offer_cancel.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/offerCancel.test.ts -// // Scenarios: // - base: create an offer then cancel it by sequence number diff --git a/tests/transactions/offer_create.rs b/tests/transactions/offer_create.rs index c1b8296c..30638822 100644 --- a/tests/transactions/offer_create.rs +++ b/tests/transactions/offer_create.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/offerCreate.test.ts -// // Scenarios: // - base: place an XRP/USD offer on the DEX // diff --git a/tests/transactions/payment.rs b/tests/transactions/payment.rs index 6385a926..9ca7d15d 100644 --- a/tests/transactions/payment.rs +++ b/tests/transactions/payment.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/payment.test.ts -// // Scenarios: // - base: XRP payment to a new (unfunded) address diff --git a/tests/transactions/payment_channel_claim.rs b/tests/transactions/payment_channel_claim.rs index 347dc190..5e033d8b 100644 --- a/tests/transactions/payment_channel_claim.rs +++ b/tests/transactions/payment_channel_claim.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/paymentChannelClaim.test.ts -// // Scenarios: // - base: create a channel, then submit a claim for 100 drops (channel source claims to destination) // @@ -9,8 +7,8 @@ // NOTE: `amount` in PaymentChannelClaim is `Option>` (raw drop string), // not XRPAmount. Pass `Some("100".into())` for 100 drops. // -// NOTE: xrpl.js computes the channel ID via hashPaymentChannel(). We read it from -// account_objects instead since xrpl-rust has no equivalent utility. +// NOTE: We read the channel ID from account_objects since xrpl-rust has no +// hashPaymentChannel utility. use crate::common::{ generate_funded_wallet, get_client, ledger_accept, test_transaction, with_blockchain_lock, diff --git a/tests/transactions/payment_channel_create.rs b/tests/transactions/payment_channel_create.rs index a158effd..0048bc43 100644 --- a/tests/transactions/payment_channel_create.rs +++ b/tests/transactions/payment_channel_create.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/paymentChannelCreate.test.ts -// // Scenarios: // - base: create a payment channel from sender to destination with 100 drops and 86400s settle delay diff --git a/tests/transactions/payment_channel_fund.rs b/tests/transactions/payment_channel_fund.rs index f5939dc1..4212ed76 100644 --- a/tests/transactions/payment_channel_fund.rs +++ b/tests/transactions/payment_channel_fund.rs @@ -1,11 +1,8 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/paymentChannelFund.test.ts -// // Scenarios: // - base: create a channel, then add 100 more drops via PaymentChannelFund // -// NOTE: xrpl.js computes the channel ID via hashPaymentChannel(account, dest, seq). -// xrpl-rust has no equivalent utility, so we read the channel ID from account_objects -// after the PaymentChannelCreate is validated. +// NOTE: xrpl-rust has no hashPaymentChannel utility, so we read the channel ID from +// account_objects after the PaymentChannelCreate is validated. use crate::common::{ generate_funded_wallet, get_client, ledger_accept, test_transaction, with_blockchain_lock, diff --git a/tests/transactions/set_regular_key.rs b/tests/transactions/set_regular_key.rs index 74eb2d42..bf9d131e 100644 --- a/tests/transactions/set_regular_key.rs +++ b/tests/transactions/set_regular_key.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: no dedicated test file in xrpl.js integration suite. -// // Scenarios: // - base: assign a regular key to a wallet // - remove: remove the regular key from a wallet (regular_key = None) diff --git a/tests/transactions/signer_list_set.rs b/tests/transactions/signer_list_set.rs index 142e0111..342bd8f4 100644 --- a/tests/transactions/signer_list_set.rs +++ b/tests/transactions/signer_list_set.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/signerListSet.test.ts -// // Scenarios: // - add: set a signer list with two signers and quorum 2 // - remove: clear the signer list by setting SignerQuorum to 0 diff --git a/tests/transactions/ticket_create.rs b/tests/transactions/ticket_create.rs index cd56cdd2..18c56d8e 100644 --- a/tests/transactions/ticket_create.rs +++ b/tests/transactions/ticket_create.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: no dedicated test file in xrpl.js integration suite. -// // Scenarios: // - base: create 2 tickets and verify both ticket objects appear in account_objects diff --git a/tests/transactions/trust_set.rs b/tests/transactions/trust_set.rs index 60b386f7..15ca6843 100644 --- a/tests/transactions/trust_set.rs +++ b/tests/transactions/trust_set.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/trustSet.test.ts -// // Scenarios: // - base: set a USD trust line to a locally funded issuer // diff --git a/tests/transactions/xchain_account_create_commit.rs b/tests/transactions/xchain_account_create_commit.rs index 6f482b3f..f1f36b4c 100644 --- a/tests/transactions/xchain_account_create_commit.rs +++ b/tests/transactions/xchain_account_create_commit.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/xchainAccountCreateCommit.test.ts -// // Scenarios: // - base: committer funds creation of a new account on the issuing chain by locking // 10_000_000 drops + signature_reward on the locking chain door diff --git a/tests/transactions/xchain_add_account_create_attestation.rs b/tests/transactions/xchain_add_account_create_attestation.rs index 68454fc7..84c2c288 100644 --- a/tests/transactions/xchain_add_account_create_attestation.rs +++ b/tests/transactions/xchain_add_account_create_attestation.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/xchainAddAccountCreateAttestation.test.ts -// // Scenarios: // - base: witness submits an account-create attestation for a 300 XRP transfer // to a new (unfunded) destination address. diff --git a/tests/transactions/xchain_add_claim_attestation.rs b/tests/transactions/xchain_add_claim_attestation.rs index 9a5d662c..d95dfd10 100644 --- a/tests/transactions/xchain_add_claim_attestation.rs +++ b/tests/transactions/xchain_add_claim_attestation.rs @@ -1,13 +1,10 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/xchainAddClaimAttestation.test.ts -// // Scenarios: // - base: witness submits a claim attestation for a transfer of 10 XRP. -// The attestation payload is binary-encoded and signed with the witness private key, -// matching the same flow as xrpl.js: encode(attestationToSign) → sign(encoded, privateKey). +// The attestation payload is binary-encoded and signed with the witness private key. // // NOTE: XChainAddClaimAttestation has NO flags; standard 9 common-field order. // -// Attestation signing flow (mirrors ripple-binary-codec + ripple-keypairs in xrpl.js): +// Attestation signing flow: // 1. Build a struct with the attestation fields (PascalCase serde names). // 2. Binary-encode with xrpl::core::binarycodec::encode → hex string. // 3. Hex-decode to bytes. diff --git a/tests/transactions/xchain_claim.rs b/tests/transactions/xchain_claim.rs index 438503b6..e55dbcec 100644 --- a/tests/transactions/xchain_claim.rs +++ b/tests/transactions/xchain_claim.rs @@ -1,12 +1,10 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/xchainClaim.test.ts -// // Scenarios: // - base: full cross-chain claim flow: // 1. XChainCreateClaimID — destination reserves claim ID 1, paying signature_reward // 2. XChainAddClaimAttestation — witness attests to a 10 XRP transfer (NO Destination) // 3. XChainClaim — destination explicitly claims the 10 XRP // -// The attestation does NOT include `Destination` — matching the xrpl.js test exactly. +// The attestation does NOT include `Destination`. // When no Destination is in the attestation, rippled does NOT auto-deliver on quorum; // the claimant must submit XChainClaim to specify the destination. // @@ -27,7 +25,7 @@ use xrpl::models::transactions::xchain_create_claim_id::XChainCreateClaimID; use xrpl::models::{Amount, Currency, XChainBridge, XRPAmount, XRP}; use xrpl::wallet::Wallet; -/// Attestation payload for XChainAddClaimAttestation (no Destination — mirrors xrpl.js). +/// Attestation payload for XChainAddClaimAttestation (no Destination). #[derive(Serialize)] struct ClaimAttestation<'a> { #[serde(rename = "XChainBridge")] @@ -79,7 +77,7 @@ async fn test_xchain_claim_base() { ledger_accept().await; - // Step 2: Build + sign attestation payload (NO Destination — matches xrpl.js) + // Step 2: Build + sign attestation payload (NO Destination) let attestation = ClaimAttestation { xchain_bridge: XChainBridge { issuing_chain_door: crate::common::constants::GENESIS_ACCOUNT.into(), diff --git a/tests/transactions/xchain_commit.rs b/tests/transactions/xchain_commit.rs index 2db12e46..9705a83d 100644 --- a/tests/transactions/xchain_commit.rs +++ b/tests/transactions/xchain_commit.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/xchainCommit.test.ts -// // Scenarios: // - base: committer locks 10_000_000 drops onto the locking chain door (XChainClaimID = 1) // diff --git a/tests/transactions/xchain_create_bridge.rs b/tests/transactions/xchain_create_bridge.rs index c501822b..e8c9a681 100644 --- a/tests/transactions/xchain_create_bridge.rs +++ b/tests/transactions/xchain_create_bridge.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/xchainCreateBridge.test.ts -// // Scenarios: // - base: door account creates an XRP/XRP bridge with genesis as the issuing chain door // diff --git a/tests/transactions/xchain_create_claim_id.rs b/tests/transactions/xchain_create_claim_id.rs index e93baaf9..790e43ba 100644 --- a/tests/transactions/xchain_create_claim_id.rs +++ b/tests/transactions/xchain_create_claim_id.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/xchainCreateClaimID.test.ts -// // Scenarios: // - base: claim ID holder creates a claim ID on an existing bridge // diff --git a/tests/transactions/xchain_modify_bridge.rs b/tests/transactions/xchain_modify_bridge.rs index 4cc3e3e2..b45a3bfd 100644 --- a/tests/transactions/xchain_modify_bridge.rs +++ b/tests/transactions/xchain_modify_bridge.rs @@ -1,5 +1,3 @@ -// xrpl.js reference: xrpl.js/packages/xrpl/test/integration/transactions/xchainModifyBridge.test.ts -// // Scenarios: // - base: create a bridge then modify the signature_reward from 200 to 300 drops // From 9a38962976facd3c78a337da46dda06944c32938 Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Fri, 1 May 2026 14:51:41 -0400 Subject: [PATCH 02/19] separate integration tests from unit tests --- .github/workflows/unit_test.yml | 17 +- src/models/results/account_tx.rs | 114 +++++++ src/models/results/ledger_entry.rs | 58 ++++ src/models/results/mod.rs | 296 ++++++++++++++++++ src/models/results/nftoken.rs | 129 ++++++++ src/models/results/tx.rs | 182 +++++++++++ src/models/transactions/check_cancel.rs | 24 ++ src/models/transactions/check_create.rs | 28 ++ src/models/transactions/escrow_cancel.rs | 23 ++ .../transactions/payment_channel_fund.rs | 29 ++ src/models/transactions/set_regular_key.rs | 22 ++ src/models/transactions/ticket_create.rs | 23 ++ src/transaction/mod.rs | 57 ++++ src/utils/txn_parser/utils/mod.rs | 95 ++++++ src/utils/txn_parser/utils/nodes.rs | 89 ++++++ src/utils/txn_parser/utils/parser.rs | 97 ++++++ 16 files changed, 1279 insertions(+), 4 deletions(-) diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index 7e110f74..5938feb1 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -41,15 +41,24 @@ jobs: - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov + # Integration-territory files (CLI, async network clients, sync wrappers + # around network calls, faucet) are excluded from unit-test coverage. + # They are exercised by tests under tests/ behind the `integration` + # feature flag, which run against a live rippled in a separate workflow. + # Mirrors xrpl-py's split between tests/unit/ (--fail-under=85) and + # tests/integration/ (--fail-under=70). - name: Generate coverage report - run: cargo llvm-cov --lcov --output-path lcov.info + run: | + cargo llvm-cov --lcov --output-path lcov.info \ + --ignore-filename-regex 'src/(bin|cli|clients|account|ledger|asynch/clients|asynch/account|asynch/ledger)/|src/transaction/mod\.rs|src/wallet/faucet_generation\.rs' - name: Check coverage thresholds run: | cargo llvm-cov --summary-only \ - --fail-under-lines 80 \ - --fail-under-regions 78 \ - --fail-under-functions 70 + --ignore-filename-regex 'src/(bin|cli|clients|account|ledger|asynch/clients|asynch/account|asynch/ledger)/|src/transaction/mod\.rs|src/wallet/faucet_generation\.rs' \ + --fail-under-lines 85 \ + --fail-under-regions 86 \ + --fail-under-functions 76 - name: Upload coverage report uses: actions/upload-artifact@v4 diff --git a/src/models/results/account_tx.rs b/src/models/results/account_tx.rs index 065778f7..349dbbe2 100644 --- a/src/models/results/account_tx.rs +++ b/src/models/results/account_tx.rs @@ -307,3 +307,117 @@ mod test_serde { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::results::{fee, ResponseStatus}; + use serde_json::json; + + fn default_account_tx() -> AccountTx<'static> { + AccountTx::default() + } + + #[test] + fn test_account_tx_version_map_default() { + let map = AccountTxVersionMap::default(); + match map { + AccountTxVersionMap::Default(_) => {} + _ => panic!("expected default variant"), + } + } + + #[test] + fn test_try_from_xrpl_result_for_account_tx_success() { + let map = AccountTxVersionMap::Default(default_account_tx()); + let wrapped = XRPLResult::AccountTx(map.clone()); + let recovered: AccountTxVersionMap = wrapped.try_into().unwrap(); + assert_eq!(recovered, map); + } + + #[test] + fn test_try_from_xrpl_result_for_account_tx_other_fallback() { + // The Other(Value) fallback: re-deserializes from JSON. + let value = json!({ + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "transactions": [] + }); + let wrapped: XRPLResult = value.into(); + let recovered: AccountTxVersionMap = wrapped.try_into().unwrap(); + match recovered { + AccountTxVersionMap::Default(tx) => { + assert_eq!(tx.base.account, "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"); + } + _ => panic!("expected default variant"), + } + } + + #[test] + fn test_try_from_xrpl_result_for_account_tx_wrong_variant() { + let wrapped = XRPLResult::Fee(fee::Fee::default()); + let recovered: Result = wrapped.try_into(); + assert!(recovered.is_err()); + assert!(recovered.unwrap_err().to_string().contains("AccountTx")); + } + + #[test] + fn test_try_from_xrpl_response_for_account_tx() { + let response = XRPLResponse { + id: None, + error: None, + error_code: None, + error_message: None, + forwarded: None, + request: None, + result: Some(XRPLResult::AccountTx(AccountTxVersionMap::Default( + default_account_tx(), + ))), + raw_result: None, + status: Some(ResponseStatus::Success), + r#type: None, + warning: None, + warnings: None, + }; + let _: AccountTxVersionMap = response.try_into().unwrap(); + } + + #[test] + fn test_try_from_xrpl_response_for_account_tx_no_result() { + let response = XRPLResponse { + id: None, + error: None, + error_code: None, + error_message: None, + forwarded: None, + request: None, + result: None, + raw_result: None, + status: None, + r#type: None, + warning: None, + warnings: None, + }; + let recovered: Result = response.try_into(); + assert!(recovered.is_err()); + } + + #[test] + fn test_account_tx_round_trip() { + let tx = AccountTx { + base: AccountTxBase { + account: "rAccount".into(), + ledger_index_min: Some(1), + ledger_index_max: Some(100), + limit: Some(50), + transactions: alloc::vec::Vec::new(), + validated: Some(true), + marker: None, + }, + meta: None, + meta_blob: None, + }; + let serialized = serde_json::to_string(&tx).unwrap(); + let deserialized: AccountTx = serde_json::from_str(&serialized).unwrap(); + assert_eq!(tx, deserialized); + } +} diff --git a/src/models/results/ledger_entry.rs b/src/models/results/ledger_entry.rs index 830b5c40..e13a4fde 100644 --- a/src/models/results/ledger_entry.rs +++ b/src/models/results/ledger_entry.rs @@ -159,4 +159,62 @@ mod tests { "13F1A95D7AAB7108D5CE7EEAF504B2894B8C674E6D68499076441C4837282BF8" ); } + + #[test] + fn test_ledger_entry_round_trip() { + let entry = LedgerEntry { + index: "13F1A95D7AAB7108D5CE7EEAF504B2894B8C674E6D68499076441C4837282BF8".into(), + ledger_index: Some(61966146), + ledger_hash: Some( + "31850E8E48E76D1064651DF39DF4E9542E8C90A9A9B629F4DE339EB3FA74F726".into(), + ), + node: Some(Node { + account: "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn".into(), + account_txn_id: None, + balance: "424021949".into(), + domain: Some("6D64756F31332E636F6D".into()), + email_hash: None, + flags: 9568256, + ledger_entry_type: "AccountRoot".into(), + message_key: None, + owner_count: 12, + previous_txn_id: "4E0AA11CBDD1760DE95B68DF2ABBE75C9698CEB548BEA9789053FCB3EBD444FB" + .into(), + previous_txn_lgr_seq: 61965653, + regular_key: None, + sequence: 385, + transfer_rate: None, + index: "13F1A95D7AAB7108D5CE7EEAF504B2894B8C674E6D68499076441C4837282BF8" + .into(), + }), + node_binary: None, + deleted_ledger_index: None, + validated: Some(true), + }; + + let serialized = serde_json::to_string(&entry).unwrap(); + let deserialized: LedgerEntry = serde_json::from_str(&serialized).unwrap(); + assert_eq!(entry, deserialized); + } + + #[test] + fn test_ledger_entry_default() { + let entry: LedgerEntry = LedgerEntry::default(); + assert_eq!(entry.index, ""); + assert!(entry.node.is_none()); + } + + #[test] + fn test_ledger_entry_node_binary_only() { + let json = r#"{ + "index": "ABC", + "ledger_index": 1, + "node_binary": "AABBCC", + "validated": false + }"#; + let entry: LedgerEntry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.node_binary.as_deref(), Some("AABBCC")); + assert!(entry.node.is_none()); + assert_eq!(entry.validated, Some(false)); + } } diff --git a/src/models/results/mod.rs b/src/models/results/mod.rs index f1802ec8..c129256b 100644 --- a/src/models/results/mod.rs +++ b/src/models/results/mod.rs @@ -912,3 +912,299 @@ pub struct XRPLWarning<'a> { pub message: Cow<'a, str>, pub forwarded: Option, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn fee_result() -> fee::Fee<'static> { + fee::Fee { + current_ledger_size: "14".into(), + current_queue_size: "0".into(), + drops: fee::Drops { + base_fee: "10".into(), + median_fee: "11000".into(), + minimum_fee: "10".into(), + open_ledger_fee: "10".into(), + }, + expected_ledger_size: "24".into(), + ledger_current_index: 26575101, + levels: fee::Levels { + median_level: "281600".into(), + minimum_level: "256".into(), + open_ledger_level: "256".into(), + reference_level: "256".into(), + }, + max_queue_size: None, + } + } + + fn random_result() -> random::Random<'static> { + random::Random { + random: "8ED765AEBBD6767603C2C9375B2679AEC76E6A8133EF59F04F9FC1AAA70E41AF".into(), + } + } + + #[test] + fn test_from_into_xrpl_result() { + let fee = fee_result(); + let result: XRPLResult = fee.clone().into(); + match &result { + XRPLResult::Fee(f) => assert_eq!(f, &fee), + _ => panic!("expected Fee variant"), + } + + let random = random_result(); + let result: XRPLResult = random.clone().into(); + assert_eq!(result.get_name(), "Random"); + } + + #[test] + fn test_try_from_xrpl_result_success() { + let fee = fee_result(); + let result: XRPLResult = fee.clone().into(); + let recovered: fee::Fee = result.try_into().unwrap(); + assert_eq!(recovered, fee); + } + + #[test] + fn test_try_from_xrpl_result_wrong_variant() { + let result: XRPLResult = random_result().into(); + let recovered: Result = result.try_into(); + assert!(recovered.is_err()); + let err = recovered.unwrap_err().to_string(); + assert!(err.contains("Fee")); + assert!(err.contains("Random")); + } + + #[test] + fn test_get_name_for_variants() { + let cases: &[(XRPLResult, &str)] = &[ + (XRPLResult::Fee(fee_result()), "Fee"), + (XRPLResult::Random(random_result()), "Random"), + (XRPLResult::Ping(ping::Ping::default()), "Ping"), + ]; + for (result, expected) in cases { + assert_eq!(result.get_name(), *expected); + } + + // Other variant + let other: XRPLResult = json!({"foo": "bar"}).into(); + assert_eq!(other.get_name(), "Other"); + } + + #[test] + fn test_xrpl_other_result_get() { + let other: XRPLOtherResult = json!({ + "value": 42, + "name": "test" + }) + .into(); + assert_eq!(other.get("value").and_then(|v| v.as_i64()), Some(42)); + assert!(other.get("missing").is_none()); + let v: u32 = other.try_get_typed("value").unwrap(); + assert_eq!(v, 42); + let missing: Result = other.try_get_typed("missing"); + assert!(missing.is_err()); + } + + #[test] + fn test_xrpl_other_result_try_from_xrpl_result() { + let other: XRPLResult = json!({"x": 1}).into(); + let recovered: XRPLOtherResult = other.try_into().unwrap(); + assert_eq!(recovered.get("x").and_then(|v| v.as_i64()), Some(1)); + + let fee: XRPLResult = fee_result().into(); + let recovered: Result = fee.try_into(); + assert!(recovered.is_err()); + } + + #[test] + fn test_response_deserialize_success() { + let json = r#"{ + "result": { + "current_ledger_size": "14", + "current_queue_size": "0", + "drops": { + "base_fee": "10", + "median_fee": "11000", + "minimum_fee": "10", + "open_ledger_fee": "10" + }, + "expected_ledger_size": "24", + "ledger_current_index": 26575101, + "levels": { + "median_level": "281600", + "minimum_level": "256", + "open_ledger_level": "256", + "reference_level": "256" + } + }, + "status": "success", + "type": "response" + }"#; + let response: XRPLResponse = serde_json::from_str(json).unwrap(); + assert!(response.is_success()); + assert_eq!(response.status, Some(ResponseStatus::Success)); + assert_eq!(response.r#type, Some(ResponseType::Response)); + assert!(response.result.is_some()); + assert!(response.raw_result.is_some()); + let fee: fee::Fee = response.try_into().unwrap(); + assert_eq!(fee.ledger_current_index, 26575101); + } + + #[test] + fn test_response_deserialize_error() { + let json = r#"{ + "error": "noNetwork", + "error_code": 16, + "error_message": "Not synced to the network.", + "status": "error" + }"#; + let response: XRPLResponse = serde_json::from_str(json).unwrap(); + assert!(!response.is_success()); + assert_eq!(response.error.as_deref(), Some("noNetwork")); + assert_eq!(response.error_code, Some(16)); + assert_eq!(response.status, Some(ResponseStatus::Error)); + + // Try-into Result reports the error message. + let result: Result = response.try_into(); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Not synced")); + } + + #[test] + fn test_response_deserialize_empty_fails() { + let json = "{}"; + let response: Result = serde_json::from_str(json); + assert!(response.is_err()); + } + + #[test] + fn test_response_deserialize_subscription_stream() { + // Subscription stream items have no `result` and no `error_code`. + let json = r#"{ + "type": "ledgerClosed", + "ledger_index": 12345 + }"#; + let response: XRPLResponse = serde_json::from_str(json).unwrap(); + // Stream items go into result as Other. + assert!(response.result.is_some()); + // No status set for stream items + assert!(response.status.is_none()); + } + + #[test] + fn test_try_into_xrpl_result_no_result_field() { + let response = XRPLResponse { + id: None, + error: None, + error_code: None, + error_message: None, + forwarded: None, + request: None, + result: None, + raw_result: None, + status: Some(ResponseStatus::Success), + r#type: None, + warning: None, + warnings: None, + }; + let result: Result = response.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_try_into_value() { + let other: XRPLResult = json!({"k": "v"}).into(); + let value: Value = other.try_into().unwrap(); + assert_eq!(value.get("k").and_then(|v| v.as_str()), Some("v")); + + let fee_value: Value = XRPLResult::Fee(fee_result()).try_into().unwrap(); + assert_eq!(fee_value.get("ledger_current_index").and_then(|v| v.as_u64()), Some(26575101)); + } + + #[test] + fn test_response_status_serde() { + assert_eq!( + serde_json::to_string(&ResponseStatus::Success).unwrap(), + "\"success\"" + ); + assert_eq!( + serde_json::from_str::("\"error\"").unwrap(), + ResponseStatus::Error + ); + } + + #[test] + fn test_response_type_serde() { + // Uses camelCase + assert_eq!( + serde_json::to_string(&ResponseType::LedgerClosed).unwrap(), + "\"ledgerClosed\"" + ); + assert_eq!( + serde_json::from_str::("\"transaction\"").unwrap(), + ResponseType::Transaction + ); + } + + #[test] + fn test_is_success_inferred_from_result_status_field() { + // No top-level status. The result is `Other(Value)` — its serialized + // form preserves the inner `status: success` field, so `is_success` + // can find it. + let response = XRPLResponse { + id: None, + error: None, + error_code: None, + error_message: None, + forwarded: None, + request: None, + result: Some(json!({"status": "success", "foo": "bar"}).into()), + raw_result: None, + status: None, + r#type: None, + warning: None, + warnings: None, + }; + assert!(response.is_success()); + + // status: "error" inside the result → not success + let response = XRPLResponse { + id: None, + error: None, + error_code: None, + error_message: None, + forwarded: None, + request: None, + result: Some(json!({"status": "error"}).into()), + raw_result: None, + status: None, + r#type: None, + warning: None, + warnings: None, + }; + assert!(!response.is_success()); + } + + #[test] + fn test_response_with_no_status_or_result_is_not_success() { + let response = XRPLResponse { + id: None, + error: None, + error_code: None, + error_message: None, + forwarded: None, + request: None, + result: None, + raw_result: None, + status: None, + r#type: None, + warning: None, + warnings: None, + }; + assert!(!response.is_success()); + } +} diff --git a/src/models/results/nftoken.rs b/src/models/results/nftoken.rs index 93f6e9bb..84cf38d6 100644 --- a/src/models/results/nftoken.rs +++ b/src/models/results/nftoken.rs @@ -84,3 +84,132 @@ impl_try_from_tx_version_map!( Cow<'a, [Cow<'a, str>]> ); impl_try_from_tx_version_map!(NFTokenAcceptOfferResult, nftoken_id, Cow<'a, str>); + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::results::tx::{Tx, TxBase, TxV1}; + + fn meta_with( + nftoken_id: Option<&str>, + offer_id: Option<&str>, + nftoken_ids: Option<&[&str]>, + ) -> TransactionMetadata<'static> { + let mut meta_value = serde_json::json!({ + "AffectedNodes": [], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }); + if let Some(id) = nftoken_id { + meta_value["nftoken_id"] = id.into(); + } + if let Some(id) = offer_id { + meta_value["offer_id"] = id.into(); + } + if let Some(ids) = nftoken_ids { + meta_value["nftoken_ids"] = + serde_json::Value::Array(ids.iter().map(|s| (*s).into()).collect()); + } + serde_json::from_value(meta_value).unwrap() + } + + fn make_tx_default(meta: Option>) -> TxVersionMap<'static> { + TxVersionMap::Default(Tx { + base: TxBase { + hash: "ABCD".into(), + ledger_index: Some(1), + ctid: None, + date: None, + validated: Some(true), + in_ledger: None, + }, + tx_json: serde_json::Value::Null, + meta, + meta_blob: None, + tx_blob: None, + }) + } + + fn make_tx_v1(meta: Option>) -> TxVersionMap<'static> { + TxVersionMap::V1(TxV1 { + base: TxBase { + hash: "ABCD".into(), + ledger_index: Some(1), + ctid: None, + date: None, + validated: Some(true), + in_ledger: None, + }, + meta, + tx: None, + tx_json: serde_json::Value::Null, + }) + } + + #[test] + fn test_mint_result_success_default() { + let meta = meta_with(Some("0008000044CDDA"), None, None); + let tx = make_tx_default(Some(meta)); + let result: NFTokenMintResult = tx.try_into().unwrap(); + assert_eq!(result.nftoken_id, "0008000044CDDA"); + } + + #[test] + fn test_mint_result_success_v1() { + let meta = meta_with(Some("0008000044CDDA"), None, None); + let tx = make_tx_v1(Some(meta)); + let result: NFTokenMintResult = tx.try_into().unwrap(); + assert_eq!(result.nftoken_id, "0008000044CDDA"); + } + + #[test] + fn test_mint_result_missing_field() { + let meta = meta_with(None, None, None); + let tx = make_tx_default(Some(meta)); + let result: Result = tx.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_mint_result_no_meta() { + let tx = make_tx_default(None); + let result: Result = tx.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_create_offer_result_success() { + let meta = meta_with(None, Some("AABBCCDD"), None); + let tx = make_tx_default(Some(meta)); + let result: NFTokenCreateOfferResult = tx.try_into().unwrap(); + assert_eq!(result.offer_id, "AABBCCDD"); + } + + #[test] + fn test_cancel_offer_result_success() { + let meta = meta_with(None, None, Some(&["ID1", "ID2"])); + let tx = make_tx_default(Some(meta)); + let result: NFTokenCancelOfferResult = tx.try_into().unwrap(); + assert_eq!(result.nftoken_ids.as_ref(), &["ID1", "ID2"]); + } + + #[test] + fn test_accept_offer_result_success() { + let meta = meta_with(Some("0008000044CDDA"), None, None); + let tx = make_tx_default(Some(meta)); + let result: NFTokenAcceptOfferResult = tx.try_into().unwrap(); + assert_eq!(result.nftoken_id, "0008000044CDDA"); + } + + #[test] + fn test_mint_result_serialize() { + // Round-trip would fail because `nftoken_id` collides with the + // flattened meta's `nftoken_id`. Just check serialization works. + let result = NFTokenMintResult { + nftoken_id: "00080000".into(), + meta: meta_with(None, None, None), + }; + let serialized = serde_json::to_string(&result).unwrap(); + assert!(serialized.contains("\"nftoken_id\":\"00080000\"")); + } +} diff --git a/src/models/results/tx.rs b/src/models/results/tx.rs index c7c3b839..cdfa6a47 100644 --- a/src/models/results/tx.rs +++ b/src/models/results/tx.rs @@ -309,3 +309,185 @@ mod test_serde { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::results::{fee, ResponseStatus}; + + fn default_tx() -> Tx<'static> { + let json = r#"{ + "tx_json": {"TransactionType":"AccountSet","Account":"rAcc"}, + "ctid": "C001", + "hash": "ABCD", + "ledger_index": 100, + "validated": true + }"#; + serde_json::from_str(json).unwrap() + } + + fn v1_tx() -> TxV1<'static> { + let json = r#"{ + "TransactionType": "AccountSet", + "Account": "rAcc", + "hash": "ABCD", + "Sequence": 1 + }"#; + serde_json::from_str(json).unwrap() + } + + #[test] + fn test_tx_version_map_get_metadata_default_none() { + let tx = default_tx(); + let map = TxVersionMap::Default(tx); + assert!(map.get_transaction_metadata().is_none()); + } + + #[test] + fn test_tx_version_map_get_metadata_v1_none() { + let tx = v1_tx(); + let map = TxVersionMap::V1(tx); + assert!(map.get_transaction_metadata().is_none()); + } + + #[test] + fn test_try_from_xrpl_result_for_tx_version_map_success() { + let map = TxVersionMap::Default(default_tx()); + let result: XRPLResult = XRPLResult::Tx(map.clone()); + let recovered: TxVersionMap = result.try_into().unwrap(); + assert_eq!(recovered, map); + } + + #[test] + fn test_try_from_xrpl_result_for_tx_version_map_wrong() { + let result: XRPLResult = XRPLResult::Fee(fee::Fee::default()); + let recovered: Result = result.try_into(); + assert!(recovered.is_err()); + assert!(recovered.unwrap_err().to_string().contains("Tx")); + } + + #[test] + fn test_try_from_xrpl_response_for_tx_version_map() { + let response = XRPLResponse { + id: None, + error: None, + error_code: None, + error_message: None, + forwarded: None, + request: None, + result: Some(XRPLResult::Tx(TxVersionMap::Default(default_tx()))), + raw_result: None, + status: Some(ResponseStatus::Success), + r#type: None, + warning: None, + warnings: None, + }; + let map: TxVersionMap = response.try_into().unwrap(); + match map { + TxVersionMap::Default(_) => {} + _ => panic!("expected default variant"), + } + } + + #[test] + fn test_try_from_xrpl_response_for_tx_version_map_no_result() { + let response = XRPLResponse { + id: None, + error: None, + error_code: None, + error_message: None, + forwarded: None, + request: None, + result: None, + raw_result: None, + status: None, + r#type: None, + warning: None, + warnings: None, + }; + let recovered: Result = response.try_into(); + assert!(recovered.is_err()); + } + + #[test] + fn test_try_from_xrpl_response_for_tx_v2_only() { + let v2_response = XRPLResponse { + id: None, + error: None, + error_code: None, + error_message: None, + forwarded: None, + request: None, + result: Some(XRPLResult::Tx(TxVersionMap::Default(default_tx()))), + raw_result: None, + status: None, + r#type: None, + warning: None, + warnings: None, + }; + let _: Tx = v2_response.try_into().unwrap(); + + // Pass a V1 to a Tx (v2) try_from — should error + let v1_response = XRPLResponse { + id: None, + error: None, + error_code: None, + error_message: None, + forwarded: None, + request: None, + result: Some(XRPLResult::Tx(TxVersionMap::V1(v1_tx()))), + raw_result: None, + status: None, + r#type: None, + warning: None, + warnings: None, + }; + let recovered: Result = v1_response.try_into(); + assert!(recovered.is_err()); + } + + #[test] + fn test_try_from_xrpl_response_for_tx_v1_only() { + let v1_response = XRPLResponse { + id: None, + error: None, + error_code: None, + error_message: None, + forwarded: None, + request: None, + result: Some(XRPLResult::Tx(TxVersionMap::V1(v1_tx()))), + raw_result: None, + status: None, + r#type: None, + warning: None, + warnings: None, + }; + let _: TxV1 = v1_response.try_into().unwrap(); + + // Pass a V2 to a TxV1 try_from — should error + let v2_response = XRPLResponse { + id: None, + error: None, + error_code: None, + error_message: None, + forwarded: None, + request: None, + result: Some(XRPLResult::Tx(TxVersionMap::Default(default_tx()))), + raw_result: None, + status: None, + r#type: None, + warning: None, + warnings: None, + }; + let recovered: Result = v2_response.try_into(); + assert!(recovered.is_err()); + } + + #[test] + fn test_tx_v1_round_trip() { + let tx = v1_tx(); + let serialized = serde_json::to_string(&tx).unwrap(); + let deserialized: TxV1 = serde_json::from_str(&serialized).unwrap(); + assert_eq!(tx, deserialized); + } +} diff --git a/src/models/transactions/check_cancel.rs b/src/models/transactions/check_cancel.rs index a4b11afa..bb9e3a94 100644 --- a/src/models/transactions/check_cancel.rs +++ b/src/models/transactions/check_cancel.rs @@ -189,4 +189,28 @@ mod tests { "49647F0D748DC3FE26BDACBC57F251AADEFFF391403EC9BF87C97F67E9977FB0" ); } + + #[test] + fn test_new_constructor_and_trait_impls() { + let txn = CheckCancel::new( + "rUn84CUYbNjRoTQ6mSW7BVJPSVJNLb1QLo".into(), + None, + Some("12".into()), + Some(7_000_000), + None, + Some(123), + None, + Some(99), + None, + "49647F0D748DC3FE26BDACBC57F251AADEFFF391403EC9BF87C97F67E9977FB0".into(), + ); + assert_eq!(txn.get_transaction_type(), &TransactionType::CheckCancel); + assert_eq!(txn.get_common_fields().sequence, Some(123)); + assert_eq!(txn.get_common_fields().last_ledger_sequence, Some(7_000_000)); + assert!(txn.get_errors().is_ok()); + + let mut txn = txn; + txn.common_fields.source_tag = Some(42); + assert_eq!(txn.common_fields.source_tag, Some(42)); + } } diff --git a/src/models/transactions/check_create.rs b/src/models/transactions/check_create.rs index e53e0d29..bcc31d7f 100644 --- a/src/models/transactions/check_create.rs +++ b/src/models/transactions/check_create.rs @@ -242,4 +242,32 @@ mod tests { assert!(check_create.expiration.is_none()); assert!(check_create.invoice_id.is_none()); } + + #[test] + fn test_new_constructor_and_trait_impls() { + let txn = CheckCreate::new( + "rUn84CUYbNjRoTQ6mSW7BVJPSVJNLb1QLo".into(), + None, + Some("12".into()), + Some(8_000_000), + None, + Some(123), + None, + None, + None, + "rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy".into(), + "100000000".into(), + Some(1), + Some(570113521), + Some("6F1DFD1D0FE8A32E40E1F2C05CF1C15545BAB56B617F9C6C2D63A6B704BEF59B".into()), + ); + assert_eq!(txn.get_transaction_type(), &TransactionType::CheckCreate); + assert_eq!(txn.get_common_fields().sequence, Some(123)); + assert_eq!(txn.destination_tag, Some(1)); + assert_eq!(txn.expiration, Some(570113521)); + assert!(txn.get_errors().is_ok()); + let mut t = txn; + t.common_fields.source_tag = Some(11); + assert_eq!(t.common_fields.source_tag, Some(11)); + } } diff --git a/src/models/transactions/escrow_cancel.rs b/src/models/transactions/escrow_cancel.rs index 4ad02c06..16c9d364 100644 --- a/src/models/transactions/escrow_cancel.rs +++ b/src/models/transactions/escrow_cancel.rs @@ -188,4 +188,27 @@ mod tests { assert_eq!(escrow_cancel.owner, "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"); assert_eq!(escrow_cancel.offer_sequence, 7); } + + #[test] + fn test_new_constructor_and_trait_impls() { + let txn = EscrowCancel::new( + "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn".into(), + None, + Some("12".into()), + Some(8_000_000), + None, + Some(456), + None, + None, + None, + "rOwner".into(), + 7, + ); + assert_eq!(txn.get_transaction_type(), &TransactionType::EscrowCancel); + assert_eq!(txn.get_common_fields().sequence, Some(456)); + assert!(txn.get_errors().is_ok()); + let mut t = txn; + t.common_fields.source_tag = Some(11); + assert_eq!(t.common_fields.source_tag, Some(11)); + } } diff --git a/src/models/transactions/payment_channel_fund.rs b/src/models/transactions/payment_channel_fund.rs index 1675b24e..bb9354e2 100644 --- a/src/models/transactions/payment_channel_fund.rs +++ b/src/models/transactions/payment_channel_fund.rs @@ -289,4 +289,33 @@ mod tests { // When using tickets, sequence should be 0 assert!(payment_channel_fund.common_fields.sequence.is_none()); } + + #[test] + fn test_new_constructor_and_trait_impls() { + let txn = PaymentChannelFund::new( + "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn".into(), + None, + Some("12".into()), + Some(8_000_000), + None, + Some(123), + None, + None, + None, + XRPAmount::from("500000"), + "C1AE6DDDEEC05CF2978C0BAD6FE302948E9533691DC749DCDD3B9E5992CA6198".into(), + Some(543171558), + ); + assert_eq!( + txn.get_transaction_type(), + &TransactionType::PaymentChannelFund + ); + assert_eq!(txn.get_common_fields().sequence, Some(123)); + assert_eq!(txn.amount.0, "500000"); + assert_eq!(txn.expiration, Some(543171558)); + assert!(txn.get_errors().is_ok()); + let mut t = txn; + t.common_fields.source_tag = Some(11); + assert_eq!(t.common_fields.source_tag, Some(11)); + } } diff --git a/src/models/transactions/set_regular_key.rs b/src/models/transactions/set_regular_key.rs index 1314b53d..72187e2c 100644 --- a/src/models/transactions/set_regular_key.rs +++ b/src/models/transactions/set_regular_key.rs @@ -210,4 +210,26 @@ mod tests { ); assert!(set_regular_key.regular_key.is_none()); } + + #[test] + fn test_new_constructor_and_trait_impls() { + let txn = SetRegularKey::new( + "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn".into(), + None, + Some("12".into()), + Some(8_000_000), + None, + Some(456), + None, + None, + None, + Some("rAR8rR8sUkBoCZFawhkWzY4Y5YoyuznwD".into()), + ); + assert_eq!(txn.get_transaction_type(), &TransactionType::SetRegularKey); + assert_eq!(txn.get_common_fields().sequence, Some(456)); + assert!(txn.get_errors().is_ok()); + let mut t = txn; + t.common_fields.source_tag = Some(11); + assert_eq!(t.common_fields.source_tag, Some(11)); + } } diff --git a/src/models/transactions/ticket_create.rs b/src/models/transactions/ticket_create.rs index 2084085f..fb61e229 100644 --- a/src/models/transactions/ticket_create.rs +++ b/src/models/transactions/ticket_create.rs @@ -198,4 +198,27 @@ mod tests { assert_eq!(ticket_create.common_fields.fee.as_ref().unwrap().0, "10"); assert_eq!(ticket_create.common_fields.sequence, Some(381)); } + + #[test] + fn test_new_constructor_and_trait_impls() { + let txn = TicketCreate::new( + "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn".into(), + None, + Some("10".into()), + Some(8_000_000), + None, + Some(381), + None, + None, + None, + 5, + ); + assert_eq!(txn.get_transaction_type(), &TransactionType::TicketCreate); + assert_eq!(txn.get_common_fields().sequence, Some(381)); + assert_eq!(txn.ticket_count, 5); + assert!(txn.get_errors().is_ok()); + let mut t = txn; + t.common_fields.source_tag = Some(11); + assert_eq!(t.common_fields.source_tag, Some(11)); + } } diff --git a/src/transaction/mod.rs b/src/transaction/mod.rs index b6fae747..08bf1846 100644 --- a/src/transaction/mod.rs +++ b/src/transaction/mod.rs @@ -127,3 +127,60 @@ where signers_count, )) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::asynch::clients::AsyncJsonRpcClient; + use crate::models::transactions::account_set::AccountSet; + + fn dummy_account_set() -> AccountSet<'static> { + AccountSet::new( + "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn".into(), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ) + } + + #[test] + fn test_calculate_fee_per_transaction_type_no_client() { + // With client = None, the function returns the default net_fee of 10 + // drops without making any network calls. + let txn = dummy_account_set(); + let fee: XRPAmount = calculate_fee_per_transaction_type::<_, _, AsyncJsonRpcClient>( + &txn, None, None, + ) + .unwrap(); + assert_eq!(fee.0, "10"); + } + + #[test] + fn test_calculate_fee_per_transaction_type_with_signers_no_client() { + let txn = dummy_account_set(); + let fee: XRPAmount = + calculate_fee_per_transaction_type::<_, _, AsyncJsonRpcClient>(&txn, None, Some(3)) + .unwrap(); + // With 3 signers, the fee should be larger than the no-signer baseline. + let baseline: XRPAmount = + calculate_fee_per_transaction_type::<_, _, AsyncJsonRpcClient>(&txn, None, None) + .unwrap(); + let fee_drops: u64 = fee.0.parse().unwrap(); + let baseline_drops: u64 = baseline.0.parse().unwrap(); + assert!(fee_drops >= baseline_drops); + } +} diff --git a/src/utils/txn_parser/utils/mod.rs b/src/utils/txn_parser/utils/mod.rs index e1fcd8df..03d96306 100644 --- a/src/utils/txn_parser/utils/mod.rs +++ b/src/utils/txn_parser/utils/mod.rs @@ -106,3 +106,98 @@ pub fn negate(value: &BigDecimal) -> XRPLUtilsResult { Ok(BigDecimal::from_str(&working_value.to_string())?) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{IssuedCurrencyAmount, XRPAmount}; + + #[test] + fn test_balance_from_xrp_amount() { + let amount = Amount::XRPAmount(XRPAmount::from("100")); + let balance: Balance = amount.into(); + assert_eq!(balance.currency, "XRP"); + assert_eq!(balance.value, "100"); + assert!(balance.issuer.is_none()); + } + + #[test] + fn test_balance_from_issued_currency() { + let amount = Amount::IssuedCurrencyAmount(IssuedCurrencyAmount { + currency: "USD".into(), + value: "5.5".into(), + issuer: "rIssuer".into(), + }); + let balance: Balance = amount.into(); + assert_eq!(balance.currency, "USD"); + assert_eq!(balance.value, "5.5"); + assert_eq!(balance.issuer.as_deref(), Some("rIssuer")); + } + + #[test] + fn test_balance_to_xrp_amount() { + let balance = Balance { + currency: "XRP".into(), + value: "100".into(), + issuer: None, + }; + let amount: Amount = balance.into(); + match amount { + Amount::XRPAmount(x) => assert_eq!(x.0, "100"), + _ => panic!("expected XRPAmount"), + } + } + + #[test] + fn test_balance_to_issued_currency() { + let balance = Balance { + currency: "USD".into(), + value: "10".into(), + issuer: Some("rIssuer".into()), + }; + let amount: Amount = balance.into(); + match amount { + Amount::IssuedCurrencyAmount(ic) => { + assert_eq!(ic.currency, "USD"); + assert_eq!(ic.value, "10"); + assert_eq!(ic.issuer, "rIssuer"); + } + _ => panic!("expected IssuedCurrencyAmount"), + } + } + + #[test] + fn test_balance_to_issued_currency_no_issuer_falls_back_to_empty() { + let balance = Balance { + currency: "USD".into(), + value: "10".into(), + issuer: None, + }; + let amount: Amount = balance.into(); + match amount { + Amount::IssuedCurrencyAmount(ic) => assert_eq!(ic.issuer, ""), + _ => panic!("expected IssuedCurrencyAmount"), + } + } + + #[test] + fn test_negate_positive() { + let v = BigDecimal::from_str("123.45").unwrap(); + let n = negate(&v).unwrap(); + assert_eq!(n.to_string(), "-123.45"); + } + + #[test] + fn test_negate_negative() { + let v = BigDecimal::from_str("-50").unwrap(); + let n = negate(&v).unwrap(); + assert_eq!(n.to_string(), "50"); + } + + #[test] + fn test_negate_zero() { + let v = BigDecimal::from_str("0").unwrap(); + let n = negate(&v).unwrap(); + assert_eq!(n.to_string(), "0"); + } +} diff --git a/src/utils/txn_parser/utils/nodes.rs b/src/utils/txn_parser/utils/nodes.rs index 8c91692b..97075035 100644 --- a/src/utils/txn_parser/utils/nodes.rs +++ b/src/utils/txn_parser/utils/nodes.rs @@ -75,3 +75,92 @@ pub fn normalize_nodes<'a: 'b, 'b>(meta: &'a TransactionMetadata<'_>) -> Vec TransactionMetadata<'static> { + let json = r#"{ + "AffectedNodes": [ + { + "CreatedNode": { + "LedgerEntryType": "AccountRoot", + "LedgerIndex": "AAAA", + "NewFields": { + "Account": "rNew", + "Balance": "1000" + } + } + }, + { + "ModifiedNode": { + "LedgerEntryType": "RippleState", + "LedgerIndex": "BBBB", + "FinalFields": { + "Account": "rMod", + "Balance": "2000" + }, + "PreviousFields": { + "Balance": "1500" + }, + "PreviousTxnId": "TXNID", + "PreviousTxnLgrSeq": 42 + } + }, + { + "DeletedNode": { + "LedgerEntryType": "Offer", + "LedgerIndex": "CCCC", + "FinalFields": { + "Account": "rDel", + "Balance": "0" + } + } + } + ], + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS" + }"#; + serde_json::from_str(json).unwrap() + } + + #[test] + fn test_normalize_nodes_all_variants() { + let meta = meta(); + let normalized = normalize_nodes(&meta); + assert_eq!(normalized.len(), 3); + + // CreatedNode + assert_eq!(normalized[0].node_type, NodeType::CreatedNode); + assert_eq!(normalized[0].ledger_entry_type, LedgerEntryType::AccountRoot); + assert!(normalized[0].new_fields.is_some()); + assert!(normalized[0].final_fields.is_none()); + assert!(normalized[0].previous_fields.is_none()); + + // ModifiedNode + assert_eq!(normalized[1].node_type, NodeType::ModifiedNode); + assert_eq!(normalized[1].ledger_entry_type, LedgerEntryType::RippleState); + assert!(normalized[1].new_fields.is_none()); + assert!(normalized[1].final_fields.is_some()); + assert!(normalized[1].previous_fields.is_some()); + assert_eq!(normalized[1].previous_txn_id, Some("TXNID")); + assert_eq!(normalized[1].previous_txn_lgr_seq, Some(42)); + + // DeletedNode + assert_eq!(normalized[2].node_type, NodeType::DeletedNode); + assert_eq!(normalized[2].ledger_entry_type, LedgerEntryType::Offer); + assert!(normalized[2].new_fields.is_none()); + assert!(normalized[2].final_fields.is_some()); + } + + #[test] + fn test_normalize_nodes_empty() { + let meta: TransactionMetadata = serde_json::from_str( + r#"{"AffectedNodes":[],"TransactionIndex":0,"TransactionResult":"tesSUCCESS"}"#, + ) + .unwrap(); + let normalized = normalize_nodes(&meta); + assert_eq!(normalized.len(), 0); + } +} diff --git a/src/utils/txn_parser/utils/parser.rs b/src/utils/txn_parser/utils/parser.rs index efc2f286..dbb9e8f1 100644 --- a/src/utils/txn_parser/utils/parser.rs +++ b/src/utils/txn_parser/utils/parser.rs @@ -66,3 +66,100 @@ pub fn group_offers_by_account( account_object_groups } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::transactions::offer_create::OfferCreateFlag; + use crate::models::XRPAmount; + use crate::models::{Amount, FlagCollection}; + use crate::utils::txn_parser::utils::{OfferChange, OfferStatus}; + use alloc::vec; + + fn balance(account: &'static str, value: &'static str) -> AccountBalance<'static> { + AccountBalance { + account: account.into(), + balance: Balance { + currency: "XRP".into(), + value: value.into(), + issuer: None, + }, + } + } + + fn offer_change(account: &'static str, sequence: u32) -> AccountOfferChange<'static> { + AccountOfferChange { + maker_account: account.into(), + offer_change: OfferChange { + flags: FlagCollection::::default(), + taker_gets: Amount::XRPAmount(XRPAmount::from("100")), + taker_pays: Amount::XRPAmount(XRPAmount::from("200")), + sequence, + status: OfferStatus::Created, + maker_exchange_rate: None, + expiration_time: None, + }, + } + } + + #[test] + fn test_get_value_parses_decimal() { + let bal = Balance { + currency: "XRP".into(), + value: "123.456".into(), + issuer: None, + }; + let v = get_value(&bal).unwrap(); + assert_eq!(v.to_string(), "123.456"); + } + + #[test] + fn test_get_value_invalid_returns_err() { + let bal = Balance { + currency: "XRP".into(), + value: "not-a-number".into(), + issuer: None, + }; + assert!(get_value(&bal).is_err()); + } + + #[test] + fn test_group_balances_by_account_groups_same_account() { + let balances = vec![ + balance("rA", "1"), + balance("rB", "2"), + balance("rA", "3"), + ]; + let groups = group_balances_by_account(balances); + assert_eq!(groups.len(), 2); + let group_a = groups.iter().find(|g| g.account == "rA").unwrap(); + assert_eq!(group_a.account_balances.len(), 2); + let group_b = groups.iter().find(|g| g.account == "rB").unwrap(); + assert_eq!(group_b.account_balances.len(), 1); + } + + #[test] + fn test_group_balances_by_account_empty() { + let groups = group_balances_by_account(vec![]); + assert!(groups.is_empty()); + } + + #[test] + fn test_group_offers_by_account_groups_same_account() { + let offers = vec![ + offer_change("rA", 1), + offer_change("rB", 2), + offer_change("rA", 3), + ]; + let groups = group_offers_by_account(offers); + assert_eq!(groups.len(), 2); + let group_a = groups.iter().find(|g| g.account == "rA").unwrap(); + assert_eq!(group_a.account_offer_changes.len(), 2); + } + + #[test] + fn test_group_offers_by_account_empty() { + let groups = group_offers_by_account(vec![]); + assert!(groups.is_empty()); + } +} From 35a195241279022c0cb363f911966c8906b3c6d5 Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Fri, 1 May 2026 14:52:56 -0400 Subject: [PATCH 03/19] adjust threshold --- .github/workflows/unit_test.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index 5938feb1..91ee2724 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -34,7 +34,8 @@ jobs: RUST_BACKTRACE: 1 - name: Test for no_std - run: cargo test --release --no-default-features --features embassy-rt,core,utils,wallet,models,helpers,websocket,json-rpc + run: cargo test --release --no-default-features --features + embassy-rt,core,utils,wallet,models,helpers,websocket,json-rpc env: RUST_BACKTRACE: 1 @@ -57,8 +58,8 @@ jobs: cargo llvm-cov --summary-only \ --ignore-filename-regex 'src/(bin|cli|clients|account|ledger|asynch/clients|asynch/account|asynch/ledger)/|src/transaction/mod\.rs|src/wallet/faucet_generation\.rs' \ --fail-under-lines 85 \ - --fail-under-regions 86 \ - --fail-under-functions 76 + --fail-under-regions 85 \ + --fail-under-functions 75 - name: Upload coverage report uses: actions/upload-artifact@v4 From 40786bcde0a386c714a28316c36b28abd1a0410a Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Mon, 4 May 2026 15:13:13 -0400 Subject: [PATCH 04/19] fix formatting --- src/models/results/ledger_entry.rs | 3 +-- src/models/results/mod.rs | 7 ++++++- src/models/transactions/check_cancel.rs | 5 ++++- .../pseudo_transactions/enable_amendment.rs | 13 ++++++++++--- src/transaction/mod.rs | 7 +++---- src/utils/txn_parser/utils/nodes.rs | 10 ++++++++-- src/utils/txn_parser/utils/parser.rs | 6 +----- tests/requests/amm_info.rs | 4 +--- tests/requests/channel_verify.rs | 1 - tests/requests/random.rs | 5 +---- tests/requests/tx.rs | 13 +++++-------- 11 files changed, 40 insertions(+), 34 deletions(-) diff --git a/src/models/results/ledger_entry.rs b/src/models/results/ledger_entry.rs index e13a4fde..300110a2 100644 --- a/src/models/results/ledger_entry.rs +++ b/src/models/results/ledger_entry.rs @@ -184,8 +184,7 @@ mod tests { regular_key: None, sequence: 385, transfer_rate: None, - index: "13F1A95D7AAB7108D5CE7EEAF504B2894B8C674E6D68499076441C4837282BF8" - .into(), + index: "13F1A95D7AAB7108D5CE7EEAF504B2894B8C674E6D68499076441C4837282BF8".into(), }), node_binary: None, deleted_ledger_index: None, diff --git a/src/models/results/mod.rs b/src/models/results/mod.rs index c129256b..5bd1c426 100644 --- a/src/models/results/mod.rs +++ b/src/models/results/mod.rs @@ -1122,7 +1122,12 @@ mod tests { assert_eq!(value.get("k").and_then(|v| v.as_str()), Some("v")); let fee_value: Value = XRPLResult::Fee(fee_result()).try_into().unwrap(); - assert_eq!(fee_value.get("ledger_current_index").and_then(|v| v.as_u64()), Some(26575101)); + assert_eq!( + fee_value + .get("ledger_current_index") + .and_then(|v| v.as_u64()), + Some(26575101) + ); } #[test] diff --git a/src/models/transactions/check_cancel.rs b/src/models/transactions/check_cancel.rs index bb9e3a94..076b13e6 100644 --- a/src/models/transactions/check_cancel.rs +++ b/src/models/transactions/check_cancel.rs @@ -206,7 +206,10 @@ mod tests { ); assert_eq!(txn.get_transaction_type(), &TransactionType::CheckCancel); assert_eq!(txn.get_common_fields().sequence, Some(123)); - assert_eq!(txn.get_common_fields().last_ledger_sequence, Some(7_000_000)); + assert_eq!( + txn.get_common_fields().last_ledger_sequence, + Some(7_000_000) + ); assert!(txn.get_errors().is_ok()); let mut txn = txn; diff --git a/src/models/transactions/pseudo_transactions/enable_amendment.rs b/src/models/transactions/pseudo_transactions/enable_amendment.rs index 74877137..9308dabd 100644 --- a/src/models/transactions/pseudo_transactions/enable_amendment.rs +++ b/src/models/transactions/pseudo_transactions/enable_amendment.rs @@ -119,7 +119,9 @@ mod tests { "rrrrrrrrrrrrrrrrrrrrrhoLvTp".into(), None, Some("10".into()), - Some(FlagCollection::new(vec![EnableAmendmentFlag::TfGotMajority])), + Some(FlagCollection::new(vec![ + EnableAmendmentFlag::TfGotMajority, + ])), None, None, Some(1), @@ -143,7 +145,9 @@ mod tests { "rrrrrrrrrrrrrrrrrrrrrhoLvTp".into(), None, None, - Some(FlagCollection::new(vec![EnableAmendmentFlag::TfLostMajority])), + Some(FlagCollection::new(vec![ + EnableAmendmentFlag::TfLostMajority, + ])), None, None, None, @@ -155,6 +159,9 @@ mod tests { ); assert!(txn.has_flag(&EnableAmendmentFlag::TfLostMajority)); assert!(!txn.has_flag(&EnableAmendmentFlag::TfGotMajority)); - assert_eq!(txn.get_transaction_type(), &TransactionType::EnableAmendment); + assert_eq!( + txn.get_transaction_type(), + &TransactionType::EnableAmendment + ); } } diff --git a/src/transaction/mod.rs b/src/transaction/mod.rs index 08bf1846..480b342d 100644 --- a/src/transaction/mod.rs +++ b/src/transaction/mod.rs @@ -162,10 +162,9 @@ mod tests { // With client = None, the function returns the default net_fee of 10 // drops without making any network calls. let txn = dummy_account_set(); - let fee: XRPAmount = calculate_fee_per_transaction_type::<_, _, AsyncJsonRpcClient>( - &txn, None, None, - ) - .unwrap(); + let fee: XRPAmount = + calculate_fee_per_transaction_type::<_, _, AsyncJsonRpcClient>(&txn, None, None) + .unwrap(); assert_eq!(fee.0, "10"); } diff --git a/src/utils/txn_parser/utils/nodes.rs b/src/utils/txn_parser/utils/nodes.rs index 97075035..bc2364dc 100644 --- a/src/utils/txn_parser/utils/nodes.rs +++ b/src/utils/txn_parser/utils/nodes.rs @@ -133,14 +133,20 @@ mod tests { // CreatedNode assert_eq!(normalized[0].node_type, NodeType::CreatedNode); - assert_eq!(normalized[0].ledger_entry_type, LedgerEntryType::AccountRoot); + assert_eq!( + normalized[0].ledger_entry_type, + LedgerEntryType::AccountRoot + ); assert!(normalized[0].new_fields.is_some()); assert!(normalized[0].final_fields.is_none()); assert!(normalized[0].previous_fields.is_none()); // ModifiedNode assert_eq!(normalized[1].node_type, NodeType::ModifiedNode); - assert_eq!(normalized[1].ledger_entry_type, LedgerEntryType::RippleState); + assert_eq!( + normalized[1].ledger_entry_type, + LedgerEntryType::RippleState + ); assert!(normalized[1].new_fields.is_none()); assert!(normalized[1].final_fields.is_some()); assert!(normalized[1].previous_fields.is_some()); diff --git a/src/utils/txn_parser/utils/parser.rs b/src/utils/txn_parser/utils/parser.rs index dbb9e8f1..a0d8e7f8 100644 --- a/src/utils/txn_parser/utils/parser.rs +++ b/src/utils/txn_parser/utils/parser.rs @@ -125,11 +125,7 @@ mod tests { #[test] fn test_group_balances_by_account_groups_same_account() { - let balances = vec![ - balance("rA", "1"), - balance("rB", "2"), - balance("rA", "3"), - ]; + let balances = vec![balance("rA", "1"), balance("rB", "2"), balance("rA", "3")]; let groups = group_balances_by_account(balances); assert_eq!(groups.len(), 2); let group_a = groups.iter().find(|g| g.account == "rA").unwrap(); diff --git a/tests/requests/amm_info.rs b/tests/requests/amm_info.rs index 3f9c0305..579f920e 100644 --- a/tests/requests/amm_info.rs +++ b/tests/requests/amm_info.rs @@ -4,8 +4,7 @@ use crate::common::with_blockchain_lock; use xrpl::asynch::clients::XRPLAsyncClient; use xrpl::models::{ - requests::amm_info::AMMInfo as AMMInfoRequest, - results::amm_info::AMMInfo as AMMInfoResult, + requests::amm_info::AMMInfo as AMMInfoRequest, results::amm_info::AMMInfo as AMMInfoResult, Currency, IssuedCurrency, XRP, }; @@ -40,4 +39,3 @@ async fn test_amm_info_base() { }) .await; } - diff --git a/tests/requests/channel_verify.rs b/tests/requests/channel_verify.rs index d4637fcb..99bf15a3 100644 --- a/tests/requests/channel_verify.rs +++ b/tests/requests/channel_verify.rs @@ -37,4 +37,3 @@ async fn test_channel_verify_base() { }) .await; } - diff --git a/tests/requests/random.rs b/tests/requests/random.rs index e48c7d76..c4000bc4 100644 --- a/tests/requests/random.rs +++ b/tests/requests/random.rs @@ -17,9 +17,7 @@ async fn test_random_base() { .await .expect("random request failed"); - let result: RandomResult = response - .try_into() - .expect("failed to parse random result"); + let result: RandomResult = response.try_into().expect("failed to parse random result"); // Verify the random string is a 64-character hex value assert_eq!(result.random.len(), 64); @@ -27,4 +25,3 @@ async fn test_random_base() { }) .await; } - diff --git a/tests/requests/tx.rs b/tests/requests/tx.rs index e8d75027..becd504b 100644 --- a/tests/requests/tx.rs +++ b/tests/requests/tx.rs @@ -55,10 +55,10 @@ async fn test_tx_base() { // Query the transaction by hash let request = TxRequest::new( - None, // id - None, // binary - None, // max_ledger - None, // min_ledger + None, // id + None, // binary + None, // max_ledger + None, // min_ledger Some(tx_hash.to_string().into()), // transaction ); @@ -67,9 +67,7 @@ async fn test_tx_base() { .await .expect("tx request failed"); - let result: TxVersionMap = response - .try_into() - .expect("failed to parse tx result"); + let result: TxVersionMap = response.try_into().expect("failed to parse tx result"); // Verify the hash matches what we submitted match &result { @@ -84,4 +82,3 @@ async fn test_tx_base() { }) .await; } - From 646fb683e0de13495026b99aa099b040d300a0b4 Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Mon, 4 May 2026 15:29:41 -0400 Subject: [PATCH 05/19] fix clippy --- src/models/results/mod.rs | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/src/models/results/mod.rs b/src/models/results/mod.rs index 5bd1c426..aab0bce3 100644 --- a/src/models/results/mod.rs +++ b/src/models/results/mod.rs @@ -610,10 +610,7 @@ where { type Error = XRPLModelException; fn try_from(response: XRPLResponse<'a>) -> XRPLModelResult { - match response.result { - Some(XRPLResult::LedgerData(value)) => return Ok(value), - _ => {} - } + if let Some(XRPLResult::LedgerData(value)) = response.result { return Ok(value) } // Fallback: re-deserialize from the raw result JSON match response.raw_result { Some(raw) => serde_json::from_value(raw).map_err(Into::into), @@ -628,10 +625,7 @@ where { type Error = XRPLModelException; fn try_from(response: XRPLResponse<'a>) -> XRPLModelResult { - match response.result { - Some(XRPLResult::LedgerEntry(value)) => return Ok(value), - _ => {} - } + if let Some(XRPLResult::LedgerEntry(value)) = response.result { return Ok(value) } match response.raw_result { Some(raw) => serde_json::from_value(raw).map_err(Into::into), None => Err(XRPLModelException::MissingField("result".to_string())), @@ -704,10 +698,7 @@ where { type Error = XRPLModelException; fn try_from(response: XRPLResponse<'a>) -> XRPLModelResult { - match response.result { - Some(XRPLResult::NoRippleCheck(value)) => return Ok(value), - _ => {} - } + if let Some(XRPLResult::NoRippleCheck(value)) = response.result { return Ok(value) } match response.raw_result { Some(raw) => serde_json::from_value(raw).map_err(Into::into), None => Err(XRPLModelException::MissingField("result".to_string())), @@ -724,10 +715,7 @@ where { type Error = XRPLModelException; fn try_from(response: XRPLResponse<'a>) -> XRPLModelResult { - match response.result { - Some(XRPLResult::RipplePathFind(value)) => return Ok(value), - _ => {} - } + if let Some(XRPLResult::RipplePathFind(value)) = response.result { return Ok(value) } match response.raw_result { Some(raw) => serde_json::from_value(raw).map_err(Into::into), None => Err(XRPLModelException::MissingField("result".to_string())), @@ -780,10 +768,7 @@ where { type Error = XRPLModelException; fn try_from(response: XRPLResponse<'a>) -> XRPLModelResult { - match response.result { - Some(XRPLResult::SubmitMultisigned(value)) => return Ok(value), - _ => {} - } + if let Some(XRPLResult::SubmitMultisigned(value)) = response.result { return Ok(value) } match response.raw_result { Some(raw) => serde_json::from_value(raw).map_err(Into::into), None => Err(XRPLModelException::MissingField("result".to_string())), From 89e184769df1d1fb144f995cbd7096faba719bcf Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Mon, 4 May 2026 15:37:24 -0400 Subject: [PATCH 06/19] fix no-std tests --- src/models/ledger/objects/mod.rs | 1 + src/transaction/mod.rs | 4 +++- src/utils/txn_parser/utils/mod.rs | 1 + src/utils/txn_parser/utils/parser.rs | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/models/ledger/objects/mod.rs b/src/models/ledger/objects/mod.rs index b3b471ea..fdae63db 100644 --- a/src/models/ledger/objects/mod.rs +++ b/src/models/ledger/objects/mod.rs @@ -167,6 +167,7 @@ mod tests { use crate::models::amount::XRPAmount; use crate::models::currency::XRP; use crate::models::NoFlags; + use alloc::string::ToString; #[test] fn test_common_fields_new() { diff --git a/src/transaction/mod.rs b/src/transaction/mod.rs index 480b342d..5f0c27d0 100644 --- a/src/transaction/mod.rs +++ b/src/transaction/mod.rs @@ -128,7 +128,9 @@ where )) } -#[cfg(test)] +// std-only: the no_std AsyncJsonRpcClient is generic over BUF/T/D and can't +// be named without those parameters. The std variant is a simple struct. +#[cfg(all(test, feature = "std"))] mod tests { use super::*; use crate::asynch::clients::AsyncJsonRpcClient; diff --git a/src/utils/txn_parser/utils/mod.rs b/src/utils/txn_parser/utils/mod.rs index 03d96306..885361cb 100644 --- a/src/utils/txn_parser/utils/mod.rs +++ b/src/utils/txn_parser/utils/mod.rs @@ -111,6 +111,7 @@ pub fn negate(value: &BigDecimal) -> XRPLUtilsResult { mod tests { use super::*; use crate::models::{IssuedCurrencyAmount, XRPAmount}; + use alloc::string::ToString; #[test] fn test_balance_from_xrp_amount() { diff --git a/src/utils/txn_parser/utils/parser.rs b/src/utils/txn_parser/utils/parser.rs index a0d8e7f8..744b581a 100644 --- a/src/utils/txn_parser/utils/parser.rs +++ b/src/utils/txn_parser/utils/parser.rs @@ -74,6 +74,7 @@ mod tests { use crate::models::XRPAmount; use crate::models::{Amount, FlagCollection}; use crate::utils::txn_parser::utils::{OfferChange, OfferStatus}; + use alloc::string::ToString; use alloc::vec; fn balance(account: &'static str, value: &'static str) -> AccountBalance<'static> { From ec37ac98d7eb904ec11ef2960fa20afa520d9dad Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Mon, 4 May 2026 15:53:05 -0400 Subject: [PATCH 07/19] fix fmt --- src/models/results/mod.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/models/results/mod.rs b/src/models/results/mod.rs index aab0bce3..51630936 100644 --- a/src/models/results/mod.rs +++ b/src/models/results/mod.rs @@ -610,7 +610,9 @@ where { type Error = XRPLModelException; fn try_from(response: XRPLResponse<'a>) -> XRPLModelResult { - if let Some(XRPLResult::LedgerData(value)) = response.result { return Ok(value) } + if let Some(XRPLResult::LedgerData(value)) = response.result { + return Ok(value); + } // Fallback: re-deserialize from the raw result JSON match response.raw_result { Some(raw) => serde_json::from_value(raw).map_err(Into::into), @@ -625,7 +627,9 @@ where { type Error = XRPLModelException; fn try_from(response: XRPLResponse<'a>) -> XRPLModelResult { - if let Some(XRPLResult::LedgerEntry(value)) = response.result { return Ok(value) } + if let Some(XRPLResult::LedgerEntry(value)) = response.result { + return Ok(value); + } match response.raw_result { Some(raw) => serde_json::from_value(raw).map_err(Into::into), None => Err(XRPLModelException::MissingField("result".to_string())), @@ -698,7 +702,9 @@ where { type Error = XRPLModelException; fn try_from(response: XRPLResponse<'a>) -> XRPLModelResult { - if let Some(XRPLResult::NoRippleCheck(value)) = response.result { return Ok(value) } + if let Some(XRPLResult::NoRippleCheck(value)) = response.result { + return Ok(value); + } match response.raw_result { Some(raw) => serde_json::from_value(raw).map_err(Into::into), None => Err(XRPLModelException::MissingField("result".to_string())), @@ -715,7 +721,9 @@ where { type Error = XRPLModelException; fn try_from(response: XRPLResponse<'a>) -> XRPLModelResult { - if let Some(XRPLResult::RipplePathFind(value)) = response.result { return Ok(value) } + if let Some(XRPLResult::RipplePathFind(value)) = response.result { + return Ok(value); + } match response.raw_result { Some(raw) => serde_json::from_value(raw).map_err(Into::into), None => Err(XRPLModelException::MissingField("result".to_string())), @@ -768,7 +776,9 @@ where { type Error = XRPLModelException; fn try_from(response: XRPLResponse<'a>) -> XRPLModelResult { - if let Some(XRPLResult::SubmitMultisigned(value)) = response.result { return Ok(value) } + if let Some(XRPLResult::SubmitMultisigned(value)) = response.result { + return Ok(value); + } match response.raw_result { Some(raw) => serde_json::from_value(raw).map_err(Into::into), None => Err(XRPLModelException::MissingField("result".to_string())), From 5d986f27149ee424c731c78ae4012dd95f0b61d9 Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Tue, 12 May 2026 10:34:35 -0400 Subject: [PATCH 08/19] add CHANGELOG --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b985b43..ee8c06e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Expanded unit-test coverage and raised CI thresholds: lines `73 → 85`, regions `75 → 86`, functions `67 → 76`. +- Split unit-test and integration-test coverage measurement in CI; integration-territory files (CLI, async network clients, sync wrappers, faucet) are excluded from the unit-test gate. + ### Fixed +- `RipplePathFind::destination_amount` changed from `Currency<'a>` to `Amount<'a>` to match the XRPL wire format. +- `NoRippleCheckRole` no longer serializes with the `#[serde(tag = "role")]` discriminator; now emits a plain `snake_case` string matching the XRPL wire format. + ## [[v1.1.0]] ### Added From 1636d9640bf4506b601e206895c8fe0f4e36ad46 Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Tue, 12 May 2026 10:43:30 -0400 Subject: [PATCH 09/19] separate more integration tests --- src/asynch/transaction/mod.rs | 3 ++- src/asynch/transaction/submit_and_wait.rs | 1 + src/asynch/wallet/mod.rs | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/asynch/transaction/mod.rs b/src/asynch/transaction/mod.rs index 0fbe29d5..51f0ca24 100644 --- a/src/asynch/transaction/mod.rs +++ b/src/asynch/transaction/mod.rs @@ -480,7 +480,7 @@ where } } -#[cfg(all(feature = "websocket", feature = "std"))] +#[cfg(all(feature = "websocket", feature = "std", feature = "integration"))] #[cfg(test)] mod test_autofill { use super::autofill; @@ -594,6 +594,7 @@ mod test_sign { assertions::assert_transaction_multisigned(&tx); } + #[cfg(feature = "integration")] #[tokio::test] async fn test_autofill_and_sign() { let client = AsyncJsonRpcClient::connect(test_constants::TESTNET_URL.parse().unwrap()); diff --git a/src/asynch/transaction/submit_and_wait.rs b/src/asynch/transaction/submit_and_wait.rs index 06c0cbf9..782c7c8b 100644 --- a/src/asynch/transaction/submit_and_wait.rs +++ b/src/asynch/transaction/submit_and_wait.rs @@ -196,6 +196,7 @@ mod tests { }, }; + #[cfg(feature = "integration")] #[tokio::test] async fn test_submit_and_wait() { let client = AsyncJsonRpcClient::connect(test_constants::TESTNET_URL.parse().unwrap()); diff --git a/src/asynch/wallet/mod.rs b/src/asynch/wallet/mod.rs index 5aea9e6c..7345ef45 100644 --- a/src/asynch/wallet/mod.rs +++ b/src/asynch/wallet/mod.rs @@ -118,6 +118,7 @@ mod test_faucet_wallet_generation { }, }; + #[cfg(feature = "integration")] #[tokio::test] async fn test_generate_faucet_wallet() { let client = AsyncJsonRpcClient::connect(test_constants::TESTNET_URL.parse().unwrap()); From c956a66554e714ea011d5982c72ff40bb28187ca Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Tue, 12 May 2026 12:46:09 -0400 Subject: [PATCH 10/19] separate more integration tests --- .github/workflows/unit_test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index 91ee2724..63e9dfd2 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -51,12 +51,12 @@ jobs: - name: Generate coverage report run: | cargo llvm-cov --lcov --output-path lcov.info \ - --ignore-filename-regex 'src/(bin|cli|clients|account|ledger|asynch/clients|asynch/account|asynch/ledger)/|src/transaction/mod\.rs|src/wallet/faucet_generation\.rs' + --ignore-filename-regex 'src/(bin|cli|clients|account|ledger|asynch/clients|asynch/account|asynch/ledger|asynch/transaction|asynch/wallet)/|src/transaction/mod\.rs|src/wallet/faucet_generation\.rs' - name: Check coverage thresholds run: | cargo llvm-cov --summary-only \ - --ignore-filename-regex 'src/(bin|cli|clients|account|ledger|asynch/clients|asynch/account|asynch/ledger)/|src/transaction/mod\.rs|src/wallet/faucet_generation\.rs' \ + --ignore-filename-regex 'src/(bin|cli|clients|account|ledger|asynch/clients|asynch/account|asynch/ledger|asynch/transaction|asynch/wallet)/|src/transaction/mod\.rs|src/wallet/faucet_generation\.rs' \ --fail-under-lines 85 \ --fail-under-regions 85 \ --fail-under-functions 75 From 22372c364beaf7781124f973081306f84aa598f9 Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Thu, 14 May 2026 12:04:46 -0400 Subject: [PATCH 11/19] refactor modules to remove regex from tests --- .github/workflows/unit_test.yml | 24 ++- src/asynch/exceptions.rs | 38 ++--- src/asynch/transaction/exceptions.rs | 15 +- src/asynch/transaction/mod.rs | 160 +----------------- src/lib.rs | 3 +- src/models/transactions/payment.rs | 8 +- src/models/transactions/xchain_claim.rs | 2 +- src/signing/exceptions.rs | 20 +++ src/signing/mod.rs | 210 ++++++++++++++++++++++++ src/transaction/exceptions.rs | 11 +- src/transaction/multisign.rs | 41 +---- 11 files changed, 291 insertions(+), 241 deletions(-) create mode 100644 src/signing/exceptions.rs create mode 100644 src/signing/mod.rs diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index 63e9dfd2..b785ee1c 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -42,24 +42,30 @@ jobs: - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov - # Integration-territory files (CLI, async network clients, sync wrappers - # around network calls, faucet) are excluded from unit-test coverage. - # They are exercised by tests under tests/ behind the `integration` - # feature flag, which run against a live rippled in a separate workflow. + # Unit-test coverage is scoped via Cargo features rather than path + # exclusion. The minimal feature set (`std,core,utils,wallet,models`) + # compiles only the pure-logic modules — pure crypto, codec, address + # codec, transaction models, and the `signing` module. Integration- + # territory code (CLI, async network clients, sync wrappers around + # network calls, faucet client) is gated behind `helpers`/`json-rpc`/ + # `websocket`/`cli` features, so it simply isn't compiled here and + # doesn't appear in the coverage report. Those files are exercised by + # the integration-test workflow against a live rippled. # Mirrors xrpl-py's split between tests/unit/ (--fail-under=85) and # tests/integration/ (--fail-under=70). - name: Generate coverage report run: | - cargo llvm-cov --lcov --output-path lcov.info \ - --ignore-filename-regex 'src/(bin|cli|clients|account|ledger|asynch/clients|asynch/account|asynch/ledger|asynch/transaction|asynch/wallet)/|src/transaction/mod\.rs|src/wallet/faucet_generation\.rs' + cargo llvm-cov \ + --no-default-features --features std,core,utils,wallet,models \ + --lcov --output-path lcov.info - name: Check coverage thresholds run: | cargo llvm-cov --summary-only \ - --ignore-filename-regex 'src/(bin|cli|clients|account|ledger|asynch/clients|asynch/account|asynch/ledger|asynch/transaction|asynch/wallet)/|src/transaction/mod\.rs|src/wallet/faucet_generation\.rs' \ - --fail-under-lines 85 \ + --no-default-features --features std,core,utils,wallet,models \ + --fail-under-lines 83 \ --fail-under-regions 85 \ - --fail-under-functions 75 + --fail-under-functions 73 - name: Upload coverage report uses: actions/upload-artifact@v4 diff --git a/src/asynch/exceptions.rs b/src/asynch/exceptions.rs index 06ec70ce..e8c14f9a 100644 --- a/src/asynch/exceptions.rs +++ b/src/asynch/exceptions.rs @@ -4,17 +4,19 @@ use thiserror_no_std::Error; use super::clients::exceptions::XRPLClientException; #[cfg(feature = "helpers")] use super::{ - transaction::exceptions::{ - XRPLSignTransactionException, XRPLSubmitAndWaitException, XRPLTransactionHelperException, - }, + transaction::exceptions::{XRPLSubmitAndWaitException, XRPLTransactionHelperException}, wallet::exceptions::XRPLFaucetException, }; -#[cfg(feature = "helpers")] +#[cfg(feature = "wallet")] +use crate::wallet::exceptions::XRPLWalletException; +// Available whenever `signing` is compiled (which requires core+models+wallet), +// or whenever `helpers` is on — the former is a subset of the latter. +#[cfg(all(feature = "core", feature = "models", feature = "wallet"))] use crate::{ core::exceptions::XRPLCoreException, models::transactions::exceptions::XRPLTransactionFieldException, - transaction::exceptions::XRPLMultisignException, utils::exceptions::XRPLUtilsException, - wallet::exceptions::XRPLWalletException, + signing::exceptions::{XRPLMultisignException, XRPLSignTransactionException}, + utils::exceptions::XRPLUtilsException, }; use crate::{models::XRPLModelException, XRPLSerdeJsonError}; @@ -22,7 +24,7 @@ pub type XRPLHelperResult = core::result::Result for XRPLHelperException { } } -#[cfg(feature = "helpers")] -impl From for XRPLHelperException { - fn from(error: XRPLSignTransactionException) -> Self { - XRPLHelperException::XRPLTransactionHelperError( - XRPLTransactionHelperException::XRPLSignTransactionError(error), - ) - } -} +// `XRPLSignTransactionException` is now a direct variant of +// `XRPLHelperException` (see `XRPLSignTransactionError`), so the `From` impl +// is derived automatically. #[cfg(feature = "helpers")] impl From for XRPLHelperException { diff --git a/src/asynch/transaction/exceptions.rs b/src/asynch/transaction/exceptions.rs index 5fe4922f..5fb071f5 100644 --- a/src/asynch/transaction/exceptions.rs +++ b/src/asynch/transaction/exceptions.rs @@ -3,6 +3,10 @@ use core::num::ParseIntError; use alloc::string::String; use thiserror_no_std::Error; +// XRPLSignTransactionException now lives in `crate::signing::exceptions`. +// Re-exported here for backward compatibility. +pub use crate::signing::exceptions::XRPLSignTransactionException; + #[derive(Error, Debug, PartialEq)] #[non_exhaustive] pub enum XRPLTransactionHelperException { @@ -18,17 +22,6 @@ pub enum XRPLTransactionHelperException { XRPLSubmitAndWaitError(#[from] XRPLSubmitAndWaitException), } -#[derive(Debug, Clone, PartialEq, Eq, Error)] -#[non_exhaustive] -pub enum XRPLSignTransactionException { - #[error("{0:?} value does not match X-Address tag")] - TagFieldMismatch(String), - #[error("Fee value of {0:?} is likely entered incorrectly, since it is much larger than the typical XRP transaction cost. If this is intentional, use `check_fee=Some(false)`.")] - FeeTooHigh(String), - #[error("Wallet is required to sign transaction")] - WalletRequired, -} - #[derive(Debug, Clone, PartialEq, Eq, Error)] #[non_exhaustive] pub enum XRPLSubmitAndWaitException { diff --git a/src/asynch/transaction/mod.rs b/src/asynch/transaction/mod.rs index 51f0ca24..1e910734 100644 --- a/src/asynch/transaction/mod.rs +++ b/src/asynch/transaction/mod.rs @@ -4,6 +4,10 @@ mod submit_and_wait; use bigdecimal::{BigDecimal, RoundingMode}; pub use submit_and_wait::*; +// `sign` lives in `crate::signing` (pure crypto, no async client required). +// Re-exported here for backward compatibility. +pub use crate::signing::sign; + use crate::{ asynch::{ account::get_next_valid_seq_number, @@ -11,34 +15,25 @@ use crate::{ ledger::{get_fee, get_latest_validated_ledger_sequence}, transaction::exceptions::XRPLSignTransactionException, }, - core::{ - addresscodec::{is_valid_xaddress, xaddress_to_classic_address}, - binarycodec::{encode, encode_for_multisigning, encode_for_signing}, - keypairs::sign as keypairs_sign, - }, + core::binarycodec::encode, models::{ requests::{server_state::ServerState, submit::Submit}, results::{server_state::ServerState as ServerStateResult, submit::Submit as SubmitResult}, - transactions::{ - exceptions::XRPLTransactionFieldException, Signer, Transaction, TransactionType, - }, + transactions::{Transaction, TransactionType}, Model, XRPAmount, XRPLModelException, }, - utils::transactions::{ - get_transaction_field_value, set_transaction_field_value, validate_transaction_has_field, - }, wallet::Wallet, }; use alloc::string::String; use alloc::string::ToString; use alloc::vec::Vec; -use alloc::{borrow::Cow, vec}; +use alloc::borrow::Cow; use core::convert::TryInto; use core::fmt::Debug; use exceptions::XRPLTransactionHelperException; use serde::Serialize; -use serde::{de::DeserializeOwned, Deserialize}; +use serde::de::DeserializeOwned; use strum::IntoEnumIterator; use super::exceptions::XRPLHelperResult; @@ -48,40 +43,6 @@ const RESTRICTED_NETWORKS: u16 = 1024; const REQUIRED_NETWORKID_VERSION: &str = "1.11.0"; const LEDGER_OFFSET: u8 = 20; -pub fn sign<'a, T, F>(transaction: &mut T, wallet: &Wallet, multisign: bool) -> XRPLHelperResult<()> -where - F: IntoEnumIterator + Serialize + Debug + PartialEq, - T: Transaction<'a, F> + Model + Serialize + DeserializeOwned + Clone + Debug, -{ - transaction.validate()?; - - if multisign { - let serialized_for_signing = - encode_for_multisigning(transaction, wallet.classic_address.clone().into())?; - let serialized_bytes = hex::decode(serialized_for_signing)?; - let signature = keypairs_sign(&serialized_bytes, &wallet.private_key)?; - let signer = Signer::new( - wallet.classic_address.clone(), - signature, - wallet.public_key.clone(), - ); - transaction.get_mut_common_fields().signers = Some(vec![signer]); - - Ok(()) - } else { - prepare_transaction(transaction, wallet)?; - // dbg!("Transaction prepared"); - let serialized_for_signing = encode_for_signing(transaction)?; - let serialized_bytes = hex::decode(serialized_for_signing)?; - // dbg!("Transaction serialized"); - let signature = keypairs_sign(&serialized_bytes, &wallet.private_key)?; - // dbg!("Signature created"); - transaction.get_mut_common_fields().txn_signature = Some(signature.into()); - - Ok(()) - } -} - pub async fn sign_and_submit<'a, 'b, T, F, C>( transaction: &mut T, client: &'b C, @@ -354,12 +315,6 @@ fn is_not_later_rippled_version(source: String, target: String) -> XRPLHelperRes } } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] -enum AccountFieldType { - Account, - Destination, -} - async fn check_txn_fee<'a, 'b, T, F, C>(transaction: &mut T, client: &'b C) -> XRPLHelperResult<()> where F: IntoEnumIterator + Serialize + Debug + PartialEq, @@ -381,105 +336,6 @@ where } } -fn prepare_transaction<'a, T, F>(transaction: &mut T, wallet: &Wallet) -> XRPLHelperResult<()> -where - F: IntoEnumIterator + Serialize + Debug + PartialEq, - T: Transaction<'a, F> + Serialize + DeserializeOwned + Clone, -{ - let commond_fields = transaction.get_mut_common_fields(); - commond_fields.signing_pub_key = Some(wallet.public_key.clone().into()); - - validate_account_xaddress(transaction, AccountFieldType::Account)?; - if validate_transaction_has_field(transaction, "Destination").is_ok() { - validate_account_xaddress(transaction, AccountFieldType::Destination)?; - } - - let _ = convert_to_classic_address(transaction, "Unauthorize"); - let _ = convert_to_classic_address(transaction, "Authorize"); - // EscrowCancel, EscrowFinish - let _ = convert_to_classic_address(transaction, "Owner"); - // SetRegularKey - - let _ = convert_to_classic_address(transaction, "RegularKey"); - - Ok(()) -} - -fn validate_account_xaddress<'a, T, F>( - prepared_transaction: &mut T, - account_field: AccountFieldType, -) -> XRPLHelperResult<()> -where - F: IntoEnumIterator + Serialize + Debug + PartialEq, - T: Transaction<'a, F> + Serialize + DeserializeOwned + Clone, -{ - let (account_field_name, tag_field_name) = match serde_json::to_string(&account_field) { - Ok(name) => { - let name_str = name.as_str().trim(); - if name_str == "\"Account\"" { - ("Account", "SourceTag") - } else if name_str == "\"Destination\"" { - ("Destination", "DestinationTag") - } else { - return Err(XRPLTransactionFieldException::UnknownAccountField( - name_str.to_string(), - ) - .into()); - } - } - Err(error) => return Err(error.into()), - }; - let account_address = match account_field { - AccountFieldType::Account => prepared_transaction.get_common_fields().account.clone(), - AccountFieldType::Destination => { - get_transaction_field_value(prepared_transaction, "Destination")? - } - }; - - if is_valid_xaddress(&account_address) { - let (address, tag, _) = xaddress_to_classic_address(&account_address)?; - validate_transaction_has_field(prepared_transaction, account_field_name)?; - set_transaction_field_value(prepared_transaction, account_field_name, address)?; - - if validate_transaction_has_field(prepared_transaction, tag_field_name).is_ok() - && get_transaction_field_value(prepared_transaction, tag_field_name).unwrap_or(Some(0)) - != tag - { - Err(XRPLSignTransactionException::TagFieldMismatch(tag_field_name.to_string()).into()) - } else { - set_transaction_field_value(prepared_transaction, tag_field_name, tag)?; - - Ok(()) - } - } else { - Ok(()) - } -} - -fn convert_to_classic_address<'a, T, F>( - transaction: &mut T, - field_name: &str, -) -> XRPLHelperResult<()> -where - F: IntoEnumIterator + Serialize + Debug + PartialEq, - T: Transaction<'a, F> + Serialize + DeserializeOwned + Clone, -{ - let address = get_transaction_field_value::(transaction, field_name)?; - if is_valid_xaddress(&address) { - let classic_address = match xaddress_to_classic_address(&address) { - Ok(t) => t.0, - Err(error) => return Err(error.into()), - }; - Ok(set_transaction_field_value( - transaction, - field_name, - classic_address, - )?) - } else { - Ok(()) - } -} - #[cfg(all(feature = "websocket", feature = "std", feature = "integration"))] #[cfg(test)] mod test_autofill { diff --git a/src/lib.rs b/src/lib.rs index c83e5dba..0f78e4c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,7 +33,6 @@ extern crate std as alloc; #[cfg(feature = "helpers")] pub mod account; -#[cfg(any(feature = "json-rpc", feature = "websocket", feature = "helpers"))] pub mod asynch; #[cfg(feature = "cli")] pub mod cli; @@ -47,6 +46,8 @@ pub mod ledger; pub mod macros; #[cfg(feature = "models")] pub mod models; +#[cfg(all(feature = "core", feature = "models", feature = "wallet"))] +pub mod signing; #[cfg(feature = "helpers")] pub mod transaction; #[cfg(feature = "utils")] diff --git a/src/models/transactions/payment.rs b/src/models/transactions/payment.rs index ccc6c156..cdec0901 100644 --- a/src/models/transactions/payment.rs +++ b/src/models/transactions/payment.rs @@ -320,15 +320,15 @@ mod tests { use crate::models::amount::{Amount, IssuedCurrencyAmount, XRPAmount}; use crate::models::{Model, PathStep}; + #[cfg(feature = "wallet")] use crate::{ - asynch::{exceptions::XRPLHelperResult, transaction::sign}, - models::transactions::Transaction, - wallet::Wallet, + asynch::exceptions::XRPLHelperResult, models::transactions::Transaction, + signing::sign, wallet::Wallet, }; use super::*; - #[cfg(all(feature = "helpers", feature = "wallet"))] + #[cfg(feature = "wallet")] #[test] fn test_payment_sign_with_memo() -> XRPLHelperResult<()> { let mut payment = Payment { diff --git a/src/models/transactions/xchain_claim.rs b/src/models/transactions/xchain_claim.rs index e27bc1e2..caddaa5b 100644 --- a/src/models/transactions/xchain_claim.rs +++ b/src/models/transactions/xchain_claim.rs @@ -121,7 +121,7 @@ mod test_sign { transactions::xchain_claim::XChainClaim, IssuedCurrency, IssuedCurrencyAmount, XChainBridge, XRP, }, - transaction::sign, + signing::sign, wallet::Wallet, }; diff --git a/src/signing/exceptions.rs b/src/signing/exceptions.rs new file mode 100644 index 00000000..131ca4d8 --- /dev/null +++ b/src/signing/exceptions.rs @@ -0,0 +1,20 @@ +use alloc::string::String; +use thiserror_no_std::Error; + +#[derive(Debug, PartialEq, Error)] +#[non_exhaustive] +pub enum XRPLMultisignException { + #[error("No signers set in the transaction. Use `sign` function with `multisign = true`.")] + NoSigners, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[non_exhaustive] +pub enum XRPLSignTransactionException { + #[error("{0:?} value does not match X-Address tag")] + TagFieldMismatch(String), + #[error("Fee value of {0:?} is likely entered incorrectly, since it is much larger than the typical XRP transaction cost. If this is intentional, use `check_fee=Some(false)`.")] + FeeTooHigh(String), + #[error("Wallet is required to sign transaction")] + WalletRequired, +} diff --git a/src/signing/mod.rs b/src/signing/mod.rs new file mode 100644 index 00000000..02c9f212 --- /dev/null +++ b/src/signing/mod.rs @@ -0,0 +1,210 @@ +//! Pure cryptographic transaction signing. +//! +//! These functions don't touch the network — they only need the wallet's +//! private key plus the transaction. They live here (rather than under +//! `asynch::transaction`) so they compile and unit-test without enabling the +//! `helpers`/`json-rpc`/`websocket` features that pull in async client code. +//! +//! Re-exported from the legacy locations (`asynch::transaction::sign`, +//! `transaction::multisign`) for backward compatibility. + +pub mod exceptions; + +use core::fmt::Debug; + +use alloc::string::String; +use alloc::string::ToString; +use alloc::vec; +use alloc::vec::Vec; +use serde::Serialize; +use serde::{de::DeserializeOwned, Deserialize}; +use strum::IntoEnumIterator; + +use crate::asynch::exceptions::XRPLHelperResult; +use crate::core::{ + addresscodec::{decode_classic_address, is_valid_xaddress, xaddress_to_classic_address}, + binarycodec::{encode_for_multisigning, encode_for_signing}, + keypairs::sign as keypairs_sign, +}; +use crate::models::{ + transactions::{exceptions::XRPLTransactionFieldException, Signer, Transaction}, + Model, +}; +use crate::utils::transactions::{ + get_transaction_field_value, set_transaction_field_value, validate_transaction_has_field, +}; +use crate::wallet::Wallet; + +use exceptions::{XRPLMultisignException, XRPLSignTransactionException}; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +enum AccountFieldType { + Account, + Destination, +} + +/// Sign a transaction with the given wallet's key. +/// +/// Pure crypto — does not contact the network. When `multisign` is true the +/// signature is appended as a `Signer` entry; otherwise it goes into +/// `TxnSignature` directly. +pub fn sign<'a, T, F>(transaction: &mut T, wallet: &Wallet, multisign: bool) -> XRPLHelperResult<()> +where + F: IntoEnumIterator + Serialize + Debug + PartialEq, + T: Transaction<'a, F> + Model + Serialize + DeserializeOwned + Clone + Debug, +{ + transaction.validate()?; + + if multisign { + let serialized_for_signing = + encode_for_multisigning(transaction, wallet.classic_address.clone().into())?; + let serialized_bytes = hex::decode(serialized_for_signing)?; + let signature = keypairs_sign(&serialized_bytes, &wallet.private_key)?; + let signer = Signer::new( + wallet.classic_address.clone(), + signature, + wallet.public_key.clone(), + ); + transaction.get_mut_common_fields().signers = Some(vec![signer]); + + Ok(()) + } else { + prepare_transaction(transaction, wallet)?; + let serialized_for_signing = encode_for_signing(transaction)?; + let serialized_bytes = hex::decode(serialized_for_signing)?; + let signature = keypairs_sign(&serialized_bytes, &wallet.private_key)?; + transaction.get_mut_common_fields().txn_signature = Some(signature.into()); + + Ok(()) + } +} + +/// Combine signer-signed copies of `transaction` into a single multisigned +/// transaction. `tx_list` must contain copies of `transaction` each signed by +/// a different signer. +pub fn multisign<'a, T, F>(transaction: &mut T, tx_list: &'a Vec) -> XRPLHelperResult<()> +where + F: IntoEnumIterator + Serialize + Debug + PartialEq + 'a, + T: Transaction<'a, F>, +{ + let mut decoded_tx_signers = Vec::new(); + for tx in tx_list { + let tx_signers = match tx.get_common_fields().signers.as_ref() { + Some(signers) => signers, + None => return Err(XRPLMultisignException::NoSigners.into()), + }; + let tx_signer = match tx_signers.first() { + Some(signer) => signer, + None => return Err(XRPLMultisignException::NoSigners.into()), + }; + decoded_tx_signers.push(tx_signer.clone()); + } + decoded_tx_signers + .sort_by_key(|signer| decode_classic_address(signer.account.as_ref()).unwrap()); + transaction.get_mut_common_fields().signers = Some(decoded_tx_signers); + transaction.get_mut_common_fields().signing_pub_key = Some("".into()); + + Ok(()) +} + +pub(crate) fn prepare_transaction<'a, T, F>( + transaction: &mut T, + wallet: &Wallet, +) -> XRPLHelperResult<()> +where + F: IntoEnumIterator + Serialize + Debug + PartialEq, + T: Transaction<'a, F> + Serialize + DeserializeOwned + Clone, +{ + let common_fields = transaction.get_mut_common_fields(); + common_fields.signing_pub_key = Some(wallet.public_key.clone().into()); + + validate_account_xaddress(transaction, AccountFieldType::Account)?; + if validate_transaction_has_field(transaction, "Destination").is_ok() { + validate_account_xaddress(transaction, AccountFieldType::Destination)?; + } + + let _ = convert_to_classic_address(transaction, "Unauthorize"); + let _ = convert_to_classic_address(transaction, "Authorize"); + // EscrowCancel, EscrowFinish + let _ = convert_to_classic_address(transaction, "Owner"); + // SetRegularKey + let _ = convert_to_classic_address(transaction, "RegularKey"); + + Ok(()) +} + +fn validate_account_xaddress<'a, T, F>( + prepared_transaction: &mut T, + account_field: AccountFieldType, +) -> XRPLHelperResult<()> +where + F: IntoEnumIterator + Serialize + Debug + PartialEq, + T: Transaction<'a, F> + Serialize + DeserializeOwned + Clone, +{ + let (account_field_name, tag_field_name) = match serde_json::to_string(&account_field) { + Ok(name) => { + let name_str = name.as_str().trim(); + if name_str == "\"Account\"" { + ("Account", "SourceTag") + } else if name_str == "\"Destination\"" { + ("Destination", "DestinationTag") + } else { + return Err(XRPLTransactionFieldException::UnknownAccountField( + name_str.to_string(), + ) + .into()); + } + } + Err(error) => return Err(error.into()), + }; + let account_address = match account_field { + AccountFieldType::Account => prepared_transaction.get_common_fields().account.clone(), + AccountFieldType::Destination => { + get_transaction_field_value(prepared_transaction, "Destination")? + } + }; + + if is_valid_xaddress(&account_address) { + let (address, tag, _) = xaddress_to_classic_address(&account_address)?; + validate_transaction_has_field(prepared_transaction, account_field_name)?; + set_transaction_field_value(prepared_transaction, account_field_name, address)?; + + if validate_transaction_has_field(prepared_transaction, tag_field_name).is_ok() + && get_transaction_field_value(prepared_transaction, tag_field_name).unwrap_or(Some(0)) + != tag + { + Err(XRPLSignTransactionException::TagFieldMismatch(tag_field_name.to_string()).into()) + } else { + set_transaction_field_value(prepared_transaction, tag_field_name, tag)?; + + Ok(()) + } + } else { + Ok(()) + } +} + +fn convert_to_classic_address<'a, T, F>( + transaction: &mut T, + field_name: &str, +) -> XRPLHelperResult<()> +where + F: IntoEnumIterator + Serialize + Debug + PartialEq, + T: Transaction<'a, F> + Serialize + DeserializeOwned + Clone, +{ + let address = get_transaction_field_value::(transaction, field_name)?; + if is_valid_xaddress(&address) { + let classic_address = match xaddress_to_classic_address(&address) { + Ok(t) => t.0, + Err(error) => return Err(error.into()), + }; + Ok(set_transaction_field_value( + transaction, + field_name, + classic_address, + )?) + } else { + Ok(()) + } +} + diff --git a/src/transaction/exceptions.rs b/src/transaction/exceptions.rs index ac3f4432..986d775d 100644 --- a/src/transaction/exceptions.rs +++ b/src/transaction/exceptions.rs @@ -1,8 +1,3 @@ -use thiserror_no_std::Error; - -#[derive(Debug, PartialEq, Error)] -#[non_exhaustive] -pub enum XRPLMultisignException { - #[error("No signers set in the transaction. Use `sign` function with `multisign = true`.")] - NoSigners, -} +// Re-exported for backward compatibility. The canonical location is +// `crate::signing::exceptions`. +pub use crate::signing::exceptions::XRPLMultisignException; diff --git a/src/transaction/multisign.rs b/src/transaction/multisign.rs index 9877cf99..776514e7 100644 --- a/src/transaction/multisign.rs +++ b/src/transaction/multisign.rs @@ -1,46 +1,15 @@ -use core::fmt::Debug; - -use alloc::vec::Vec; -use serde::Serialize; -use strum::IntoEnumIterator; - -use crate::{ - asynch::exceptions::XRPLHelperResult, core::addresscodec::decode_classic_address, - models::transactions::Transaction, transaction::exceptions::XRPLMultisignException, -}; - -pub fn multisign<'a, T, F>(transaction: &mut T, tx_list: &'a Vec) -> XRPLHelperResult<()> -where - F: IntoEnumIterator + Serialize + Debug + PartialEq + 'a, - T: Transaction<'a, F>, -{ - let mut decoded_tx_signers = Vec::new(); - for tx in tx_list { - let tx_signers = match tx.get_common_fields().signers.as_ref() { - Some(signers) => signers, - None => return Err(XRPLMultisignException::NoSigners.into()), - }; - let tx_signer = match tx_signers.first() { - Some(signer) => signer, - None => return Err(XRPLMultisignException::NoSigners.into()), - }; - decoded_tx_signers.push(tx_signer.clone()); - } - decoded_tx_signers - .sort_by_key(|signer| decode_classic_address(signer.account.as_ref()).unwrap()); - transaction.get_mut_common_fields().signers = Some(decoded_tx_signers); - transaction.get_mut_common_fields().signing_pub_key = Some("".into()); - - Ok(()) -} +// `multisign` now lives in `crate::signing`. Re-exported here for backward +// compatibility. +pub use crate::signing::multisign; #[cfg(test)] mod test { use alloc::borrow::Cow; - use super::*; use crate::asynch::transaction::sign; use crate::models::transactions::account_set::AccountSet; + use crate::models::transactions::Transaction; + use crate::signing::multisign; use crate::wallet::Wallet; #[tokio::test] From bde48bb70791acbba80661e8b013520774b082c7 Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Thu, 14 May 2026 13:13:39 -0400 Subject: [PATCH 12/19] fix fmt --- src/asynch/transaction/mod.rs | 4 ++-- src/models/transactions/payment.rs | 4 ++-- src/signing/mod.rs | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/asynch/transaction/mod.rs b/src/asynch/transaction/mod.rs index 1e910734..d7e38a11 100644 --- a/src/asynch/transaction/mod.rs +++ b/src/asynch/transaction/mod.rs @@ -25,15 +25,15 @@ use crate::{ wallet::Wallet, }; +use alloc::borrow::Cow; use alloc::string::String; use alloc::string::ToString; use alloc::vec::Vec; -use alloc::borrow::Cow; use core::convert::TryInto; use core::fmt::Debug; use exceptions::XRPLTransactionHelperException; -use serde::Serialize; use serde::de::DeserializeOwned; +use serde::Serialize; use strum::IntoEnumIterator; use super::exceptions::XRPLHelperResult; diff --git a/src/models/transactions/payment.rs b/src/models/transactions/payment.rs index cdec0901..4901aff3 100644 --- a/src/models/transactions/payment.rs +++ b/src/models/transactions/payment.rs @@ -322,8 +322,8 @@ mod tests { use crate::models::{Model, PathStep}; #[cfg(feature = "wallet")] use crate::{ - asynch::exceptions::XRPLHelperResult, models::transactions::Transaction, - signing::sign, wallet::Wallet, + asynch::exceptions::XRPLHelperResult, models::transactions::Transaction, signing::sign, + wallet::Wallet, }; use super::*; diff --git a/src/signing/mod.rs b/src/signing/mod.rs index 02c9f212..5d36a646 100644 --- a/src/signing/mod.rs +++ b/src/signing/mod.rs @@ -207,4 +207,3 @@ where Ok(()) } } - From 826e41eeab23debe9c40b46abc0f641c8ab0bfc8 Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Thu, 14 May 2026 13:27:23 -0400 Subject: [PATCH 13/19] fix build --- src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 0f78e4c4..78456c5d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,6 +33,9 @@ extern crate std as alloc; #[cfg(feature = "helpers")] pub mod account; +// `asynch::exceptions` requires `models` for `XRPLModelException`; the rest of +// `asynch` is gated internally on individual features. +#[cfg(feature = "models")] pub mod asynch; #[cfg(feature = "cli")] pub mod cli; From 667065a6049e420badb52e56a64bd0efa4477024 Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Thu, 14 May 2026 16:05:51 -0400 Subject: [PATCH 14/19] add codecov --- .github/workflows/unit_test.yml | 8 ++++++++ codecov.yml | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 codecov.yml diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index b785ee1c..69de65a1 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -67,6 +67,14 @@ jobs: --fail-under-regions 85 \ --fail-under-functions 73 + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: lcov.info + flags: unit + fail_ci_if_error: true + - name: Upload coverage report uses: actions/upload-artifact@v4 with: diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..b635e6da --- /dev/null +++ b/codecov.yml @@ -0,0 +1,21 @@ +coverage: + status: + project: + unit: + target: 83% + threshold: 1% + flags: + - unit + patch: + default: + target: 80% + threshold: 1% + +comment: + layout: "reach, diff, flags, files" + require_changes: false + +flags: + unit: + paths: + - src/ From 31e12206261396ab7903176eb4a1db802d8a3ef1 Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Thu, 14 May 2026 16:19:31 -0400 Subject: [PATCH 15/19] separate checks --- codecov.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codecov.yml b/codecov.yml index b635e6da..9a3598f2 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,7 +1,7 @@ coverage: status: project: - unit: + default: target: 83% threshold: 1% flags: From 0cc5d161c8219e305714c3941736067e55432c7f Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Mon, 18 May 2026 11:17:10 -0400 Subject: [PATCH 16/19] update CHANGELOG --- CHANGELOG.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8c06e7..9f40a49d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Expanded unit-test coverage and raised CI thresholds: lines `73 → 85`, regions `75 → 86`, functions `67 → 76`. -- Split unit-test and integration-test coverage measurement in CI; integration-territory files (CLI, async network clients, sync wrappers, faucet) are excluded from the unit-test gate. +- New `xrpl::signing` module containing the pure-crypto signing helpers (`sign`, `multisign`, `prepare_transaction`) extracted from `asynch::transaction` and `transaction`. Available with just `core + models + wallet` features (no `helpers`/runtime/client dependency). The legacy paths `asynch::transaction::sign` and `transaction::multisign` are preserved as re-exports for backward compatibility. +- Expanded unit-test coverage and raised CI thresholds: lines `73 → 83`, regions `75 → 85`, functions `67 → 73`. +- Codecov integration with per-PR project (≥83%) and patch (≥80% on new/modified lines) gates. + +### Changed + +- Unit-test and integration-test coverage are now scoped via Cargo feature flags rather than path regex. The unit-test workflow builds with `--no-default-features --features std,core,utils,wallet,models`, so integration-territory code (CLI, async clients, sync wrappers, faucet) simply isn't compiled and doesn't appear in the unit coverage report. +- Network-dependent inline tests in `src/asynch/transaction/` and `src/asynch/wallet/` (`test_autofill_txn`, `test_autofill_and_sign`, `test_submit_and_wait`, `test_generate_faucet_wallet`) are now gated behind `feature = "integration"` so `cargo test --release` is hermetic by default. ### Fixed From 041d369804111106353ca7750cf07a3b12afe9ab Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Thu, 21 May 2026 11:36:41 -0400 Subject: [PATCH 17/19] fix workflow --- .github/workflows/integration_test.yml | 2 +- .github/workflows/unit_test.yml | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 7ba3a55a..992112d7 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -31,7 +31,7 @@ jobs: --rm \ --publish 5005:5005 \ --publish 6006:6006 \ - --volume "${{ github.workspace }}/.ci-config/":"/etc/opt/xrpld/" \ + --volume "${{ github.workspace }}/.ci-config/":"/etc/xrpld/" \ --name xrpld-service \ ${{ env.XRPLD_DOCKER_IMAGE }} --standalone diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index 69de65a1..865b05ca 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -51,8 +51,6 @@ jobs: # `websocket`/`cli` features, so it simply isn't compiled here and # doesn't appear in the coverage report. Those files are exercised by # the integration-test workflow against a live rippled. - # Mirrors xrpl-py's split between tests/unit/ (--fail-under=85) and - # tests/integration/ (--fail-under=70). - name: Generate coverage report run: | cargo llvm-cov \ From 6755ff927f67088fe37339f7fc75c69175273fdf Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Thu, 21 May 2026 11:44:57 -0400 Subject: [PATCH 18/19] update docs --- CONTRIBUTING.md | 6 +++--- docker-compose.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb23b206..31a0ca0e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,7 +74,7 @@ From the `xrpl-rust` folder, run the following commands: ```bash # Sets up the xrpld standalone Docker container — skip if you already have it running docker run -p 5005:5005 -p 6006:6006 --rm -it --name xrpld_standalone \ - --volume "$PWD/.ci-config/:/etc/opt/xrpld/" \ + --volume "$PWD/.ci-config/:/etc/xrpld/" \ rippleci/xrpld:develop --standalone cargo test --release --features integration,std,json-rpc,helpers ``` @@ -91,7 +91,7 @@ Breaking down the `docker run` command: - `--rm` closes the container automatically when it exits. - `-it` keeps stdin open so you can stop the node with Ctrl-C. - `--name xrpld_standalone` is an instance name for clarity. -- `--volume $PWD/.ci-config/:/etc/opt/xrpld/`: bind-mounts the host directory (left side) into the container (right side). `xrpld.cfg` lives in `$PWD/.ci-config/`, and this command is intended to be run from the root of the `xrpl-rust` project. The `xrpld` binary searches for its configuration file inside `/etc/opt/xrpld/`. An absolute path is required, so we use `$PWD` instead of `./`. +- `--volume $PWD/.ci-config/:/etc/xrpld/`: bind-mounts the host directory (left side) into the container (right side). `xrpld.cfg` lives in `$PWD/.ci-config/`, and this command is intended to be run from the root of the `xrpl-rust` project. The `xrpld` binary searches for its configuration file inside `/etc/xrpld/`. An absolute path is required, so we use `$PWD` instead of `./`. - `rippleci/xrpld` is an image that is regularly updated with the latest `xrpld` releases (the binary formerly known as `rippled`; see xrpl.js PR #3270). - `--standalone` starts `xrpld` in standalone mode, where ledgers only close on demand. @@ -114,7 +114,7 @@ cargo llvm-cov --summary-only The CI enforces the following minimum thresholds (current baseline is ~78% lines / ~68% regions / ~75% functions, measured with default features only — integration tests are excluded from coverage): | Metric | Minimum | -|-----------|---------| +| --------- | ------- | | Lines | 75% | | Regions | 65% | | Functions | 72% | diff --git a/docker-compose.yml b/docker-compose.yml index dded3db9..781531a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: xrpld: image: rippleci/xrpld:develop volumes: - - ./.ci-config:/etc/opt/xrpld + - ./.ci-config:/etc/xrpld ports: - "5005:5005" - "6006:6006" From 967eb61d1619e24cebe188c882501e4ba4a2ec4f Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Thu, 21 May 2026 12:30:53 -0400 Subject: [PATCH 19/19] patch ledger_entry flaky test --- src/models/results/ledger_entry.rs | 154 +++++++++++------------------ src/models/results/mod.rs | 2 +- 2 files changed, 58 insertions(+), 98 deletions(-) diff --git a/src/models/results/ledger_entry.rs b/src/models/results/ledger_entry.rs index 300110a2..fa85a424 100644 --- a/src/models/results/ledger_entry.rs +++ b/src/models/results/ledger_entry.rs @@ -1,58 +1,7 @@ use alloc::borrow::Cow; use serde::{Deserialize, Serialize}; - -/// Represents an AccountRoot ledger object in the XRP Ledger. -/// This object type represents a single account, its settings, and XRP balance. -/// -/// See AccountRoot: -/// `` -#[serde_with::skip_serializing_none] -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "PascalCase")] -pub struct Node<'a> { - /// The identifying address of this account - pub account: Cow<'a, str>, - /// The identifying hash of the transaction that most recently modified - /// this object. Only present if the account has the asfAccountTxnID flag - /// enabled. - #[serde(rename = "AccountTxnID")] - pub account_txn_id: Option>, - /// The account's current XRP balance in drops - pub balance: Cow<'a, str>, - /// The domain associated with this account. The raw domain value is a - /// hex string representing the ASCII for the domain - pub domain: Option>, - /// Hash of an email address to be used for generating an avatar image - pub email_hash: Option>, - /// Various boolean flags enabled for this account - pub flags: u32, - /// The type of ledger object. For AccountRoot objects, this is always - /// "AccountRoot" - pub ledger_entry_type: Cow<'a, str>, - /// Public key for sending encrypted messages to this account - pub message_key: Option>, - /// Number of objects this account owns in the ledger, which contributes - /// to its owner reserve - pub owner_count: u32, - /// Identifying hash of the previous transaction that modified this object - #[serde(rename = "PreviousTxnID")] - pub previous_txn_id: Cow<'a, str>, - /// Ledger index of the ledger containing the previous transaction that - /// modified this object - pub previous_txn_lgr_seq: u32, - /// The identifying address of a key pair that can be used to authorize - /// transactions for this account instead of the master key - pub regular_key: Option>, - /// The sequence number of the next valid transaction for this account - pub sequence: u32, - /// The rate to charge when users transfer this account's issued currencies, - /// represented as billionths of a unit. A value of 0 means no fee - pub transfer_rate: Option, - /// The unique ID of this ledger entry - #[serde(rename = "index")] - pub index: Cow<'a, str>, -} +use serde_json::Value; /// Response format for the ledger_entry method, which returns a single ledger /// object from the XRP Ledger in its raw format. @@ -60,7 +9,7 @@ pub struct Node<'a> { /// See Ledger Entry: /// `` #[serde_with::skip_serializing_none] -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct LedgerEntry<'a> { /// The unique ID of this ledger entry. pub index: Cow<'a, str>, @@ -70,7 +19,9 @@ pub struct LedgerEntry<'a> { pub ledger_hash: Option>, /// Object containing the data of this ledger entry, according to the /// ledger format. Omitted if "binary": true specified. - pub node: Option>, + /// This is a generic JSON value because `ledger_entry` can return any + /// ledger object type (AccountRoot, DirectoryNode, Offer, etc.). + pub node: Option, /// The binary representation of the ledger object, as hexadecimal. /// Only present if "binary": true specified. pub node_binary: Option>, @@ -125,37 +76,28 @@ mod tests { assert_eq!(result.validated, Some(true)); let node = result.node.unwrap(); - assert_eq!(node.account, "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"); - assert_eq!( - node.account_txn_id, - Some("4E0AA11CBDD1760DE95B68DF2ABBE75C9698CEB548BEA9789053FCB3EBD444FB".into()) - ); - assert_eq!(node.balance, "424021949"); - assert_eq!(node.domain, Some("6D64756F31332E636F6D".into())); - assert_eq!( - node.email_hash, - Some("98B4375E1D753E5B91627516F6D70977".into()) - ); - assert_eq!(node.flags, 9568256); - assert_eq!(node.ledger_entry_type, "AccountRoot"); + assert_eq!(node["Account"], "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"); assert_eq!( - node.message_key, - Some("0000000000000000000000070000000300".into()) - ); - assert_eq!(node.owner_count, 12); - assert_eq!( - node.previous_txn_id, + node["AccountTxnID"], "4E0AA11CBDD1760DE95B68DF2ABBE75C9698CEB548BEA9789053FCB3EBD444FB" ); - assert_eq!(node.previous_txn_lgr_seq, 61965653); + assert_eq!(node["Balance"], "424021949"); + assert_eq!(node["Domain"], "6D64756F31332E636F6D"); + assert_eq!(node["EmailHash"], "98B4375E1D753E5B91627516F6D70977"); + assert_eq!(node["Flags"], 9568256); + assert_eq!(node["LedgerEntryType"], "AccountRoot"); + assert_eq!(node["MessageKey"], "0000000000000000000000070000000300"); + assert_eq!(node["OwnerCount"], 12); assert_eq!( - node.regular_key, - Some("rD9iJmieYHn8jTtPjwwkW2Wm9sVDvPXLoJ".into()) + node["PreviousTxnID"], + "4E0AA11CBDD1760DE95B68DF2ABBE75C9698CEB548BEA9789053FCB3EBD444FB" ); - assert_eq!(node.sequence, 385); - assert_eq!(node.transfer_rate, Some(4294967295)); + assert_eq!(node["PreviousTxnLgrSeq"], 61965653); + assert_eq!(node["RegularKey"], "rD9iJmieYHn8jTtPjwwkW2Wm9sVDvPXLoJ"); + assert_eq!(node["Sequence"], 385); + assert_eq!(node["TransferRate"], 4294967295u64); assert_eq!( - node.index, + node["index"], "13F1A95D7AAB7108D5CE7EEAF504B2894B8C674E6D68499076441C4837282BF8" ); } @@ -168,24 +110,18 @@ mod tests { ledger_hash: Some( "31850E8E48E76D1064651DF39DF4E9542E8C90A9A9B629F4DE339EB3FA74F726".into(), ), - node: Some(Node { - account: "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn".into(), - account_txn_id: None, - balance: "424021949".into(), - domain: Some("6D64756F31332E636F6D".into()), - email_hash: None, - flags: 9568256, - ledger_entry_type: "AccountRoot".into(), - message_key: None, - owner_count: 12, - previous_txn_id: "4E0AA11CBDD1760DE95B68DF2ABBE75C9698CEB548BEA9789053FCB3EBD444FB" - .into(), - previous_txn_lgr_seq: 61965653, - regular_key: None, - sequence: 385, - transfer_rate: None, - index: "13F1A95D7AAB7108D5CE7EEAF504B2894B8C674E6D68499076441C4837282BF8".into(), - }), + node: Some(serde_json::json!({ + "Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "Balance": "424021949", + "Domain": "6D64756F31332E636F6D", + "Flags": 9568256, + "LedgerEntryType": "AccountRoot", + "OwnerCount": 12, + "PreviousTxnID": "4E0AA11CBDD1760DE95B68DF2ABBE75C9698CEB548BEA9789053FCB3EBD444FB", + "PreviousTxnLgrSeq": 61965653, + "Sequence": 385, + "index": "13F1A95D7AAB7108D5CE7EEAF504B2894B8C674E6D68499076441C4837282BF8" + })), node_binary: None, deleted_ledger_index: None, validated: Some(true), @@ -216,4 +152,28 @@ mod tests { assert!(entry.node.is_none()); assert_eq!(entry.validated, Some(false)); } + + #[test] + fn test_ledger_entry_directory_node() { + let json = r#"{ + "index": "A832B09498B80B1B1BB0E2B31B41B8A3A4B57B8C1C23DAF43A76C6B1B3F7CD60", + "ledger_index": 100, + "node": { + "Flags": 0, + "Indexes": ["AAB..."], + "IndexNext": "0", + "IndexPrevious": "0", + "LedgerEntryType": "DirectoryNode", + "Owner": "rN7n3473SaZBCG4dFL83w7p1W9cgPLAPkS", + "RootIndex": "A832B09498B80B1B1BB0E2B31B41B8A3A4B57B8C1C23DAF43A76C6B1B3F7CD60", + "index": "A832B09498B80B1B1BB0E2B31B41B8A3A4B57B8C1C23DAF43A76C6B1B3F7CD60" + }, + "validated": true + }"#; + + let result: LedgerEntry = serde_json::from_str(json).unwrap(); + let node = result.node.unwrap(); + assert_eq!(node["LedgerEntryType"], "DirectoryNode"); + assert_eq!(node["Owner"], "rN7n3473SaZBCG4dFL83w7p1W9cgPLAPkS"); + } } diff --git a/src/models/results/mod.rs b/src/models/results/mod.rs index 51630936..5f4f6682 100644 --- a/src/models/results/mod.rs +++ b/src/models/results/mod.rs @@ -111,7 +111,7 @@ impl XRPLOtherResult { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(untagged)] #[allow(clippy::large_enum_variant)] pub enum XRPLResult<'a> {