From 75c311d2ad8d4a13268197a96fa39c01bf3b3b21 Mon Sep 17 00:00:00 2001 From: phertyameen Date: Sat, 20 Jun 2026 01:05:03 +0100 Subject: [PATCH 1/5] feat: add explanations for all Stellar transaction operation types --- .../src/explain/operation/account_merge.rs | 86 ++++ packages/core/src/explain/operation/mod.rs | 1 + packages/core/src/explain/transaction.rs | 368 +++++++++++++++++- packages/core/src/models/operation.rs | 38 +- 4 files changed, 484 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/explain/operation/account_merge.rs diff --git a/packages/core/src/explain/operation/account_merge.rs b/packages/core/src/explain/operation/account_merge.rs new file mode 100644 index 0000000..a7574ee --- /dev/null +++ b/packages/core/src/explain/operation/account_merge.rs @@ -0,0 +1,86 @@ +use crate::models::operation::AccountMergeOperation; +use serde::{Deserialize, Serialize}; + +/// Human-readable explanation of an account_merge operation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AccountMergeExplanation { + /// Full natural-language summary of the merge. + pub summary: String, + + /// The account that was merged away (and no longer exists on-chain). + pub source: String, + + /// The account that received the remaining XLM balance. + pub destination: String, +} + +/// Explain an account_merge operation. +/// +/// The source account is removed from the ledger and any remaining XLM +/// it held is transferred in full to the destination account. +pub fn explain_account_merge(op: &AccountMergeOperation) -> AccountMergeExplanation { + let summary = format!( + "{} merged their account into {}, transferring all remaining XLM", + op.source, op.destination + ); + + AccountMergeExplanation { + summary, + source: op.source.clone(), + destination: op.destination.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_account_merge(source: &str, destination: &str) -> AccountMergeOperation { + AccountMergeOperation { + id: "test_op_id".to_string(), + source: source.to_string(), + destination: destination.to_string(), + } + } + + #[test] + fn test_explain_account_merge_summary_format() { + let op = make_account_merge("GAAAA", "GBBBB"); + let explanation = explain_account_merge(&op); + assert_eq!( + explanation.summary, + "GAAAA merged their account into GBBBB, transferring all remaining XLM" + ); + } + + #[test] + fn test_explain_account_merge_fields_preserved() { + let op = make_account_merge("GSOURCE123", "GDEST456"); + let explanation = explain_account_merge(&op); + assert_eq!(explanation.source, "GSOURCE123"); + assert_eq!(explanation.destination, "GDEST456"); + } + + #[test] + fn test_explain_account_merge_summary_contains_both_accounts() { + let op = make_account_merge("GMERGEDFROM", "GMERGEDINTO"); + let explanation = explain_account_merge(&op); + assert!(explanation.summary.contains("GMERGEDFROM")); + assert!(explanation.summary.contains("GMERGEDINTO")); + } + + #[test] + fn test_explain_account_merge_mentions_transfer() { + let op = make_account_merge("GAAAA", "GBBBB"); + let explanation = explain_account_merge(&op); + assert!(explanation.summary.contains("transferring all remaining XLM")); + } + + #[test] + fn test_explain_account_merge_unknown_source() { + let op = make_account_merge("Unknown", "GBBBB"); + let explanation = explain_account_merge(&op); + assert_eq!(explanation.source, "Unknown"); + assert!(explanation.summary.starts_with("Unknown merged")); + } +} \ No newline at end of file diff --git a/packages/core/src/explain/operation/mod.rs b/packages/core/src/explain/operation/mod.rs index 302f360..4447038 100644 --- a/packages/core/src/explain/operation/mod.rs +++ b/packages/core/src/explain/operation/mod.rs @@ -2,6 +2,7 @@ //! //! Each operation type gets its own submodule. +pub mod account_merge; pub mod change_trust; pub mod clawback; pub mod create_account; diff --git a/packages/core/src/explain/transaction.rs b/packages/core/src/explain/transaction.rs index ec4843d..19691d8 100644 --- a/packages/core/src/explain/transaction.rs +++ b/packages/core/src/explain/transaction.rs @@ -1,12 +1,32 @@ -//! Transaction explanation orchestration. - use serde::{Deserialize, Serialize}; use crate::explain::memo::explain_memo; use crate::models::fee::FeeStats; +use crate::models::operation::{Operation, OfferType, PathPaymentType}; use crate::models::transaction::Transaction; +use super::operation::account_merge::explain_account_merge; +use super::operation::change_trust::explain_change_trust; +use super::operation::clawback::{explain_clawback, explain_clawback_claimable_balance}; +use super::operation::create_account::explain_create_account; +use super::operation::manage_offer::explain_manage_offer; +use super::operation::path_payment::explain_path_payment; use super::operation::payment::{PaymentExplanation, explain_payment, explain_payment_with_fee}; +use super::operation::set_options::explain_set_options; + +/// A single explained operation within a transaction, in original order. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct OperationExplanation { + /// Position of this operation within the transaction (0-based). + pub index: usize, + /// The Stellar operation type, e.g. "payment", "create_account". + #[serde(rename = "type")] + pub operation_type: String, + /// Plain-English summary of what this operation did. + pub summary: String, + /// Structured, type-specific details for this operation. + pub details: serde_json::Value, +} /// Complete explanation of a transaction. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -14,7 +34,12 @@ pub struct TransactionExplanation { pub transaction_hash: String, pub successful: bool, pub summary: String, + /// One explanation per operation, in the original order of the transaction. + pub operations: Vec, + /// Deprecated: kept for backward compatibility with existing consumers. + /// Use `operations` instead. pub payment_explanations: Vec, + /// Count of operations whose type is genuinely unsupported (i.e. mapped to "Other"). pub skipped_operations: usize, /// Human-readable explanation of the transaction memo. pub memo_explanation: Option, @@ -122,7 +147,6 @@ pub fn explain_transaction_with_ledger( } let payment_count = transaction.payment_count(); - let skipped_operations = total_operations - payment_count; let payment_explanations = transaction .payment_operations() @@ -133,6 +157,18 @@ pub fn explain_transaction_with_ledger( }) .collect::>(); + let operations: Vec = transaction + .operations + .iter() + .enumerate() + .map(|(index, op)| explain_operation(index, op, transaction.fee_charged, fee_stats)) + .collect(); + + let skipped_operations = operations + .iter() + .filter(|op| is_unsupported_type(&op.operation_type)) + .count(); + let base_summary = build_transaction_summary(transaction.successful, payment_count, skipped_operations); @@ -161,6 +197,7 @@ pub fn explain_transaction_with_ledger( transaction_hash: transaction.hash.clone(), successful: transaction.successful, summary, + operations, payment_explanations, skipped_operations, memo_explanation, @@ -170,6 +207,184 @@ pub fn explain_transaction_with_ledger( }) } +/// True for operation type strings that have no dedicated explainer and +/// fall back to the generic "coming soon" message. +fn is_unsupported_type(operation_type: &str) -> bool { + !matches!( + operation_type, + "payment" + | "create_account" + | "change_trust" + | "set_options" + | "account_merge" + | "manage_sell_offer" + | "manage_buy_offer" + | "path_payment_strict_send" + | "path_payment_strict_receive" + | "clawback" + | "clawback_claimable_balance" + ) +} + +/// Build the structured explanation for a single operation, preserving its +/// position within the transaction. +fn explain_operation( + index: usize, + op: &Operation, + fee_charged: u64, + fee_stats: Option<&FeeStats>, +) -> OperationExplanation { + match op { + Operation::Payment(payment) => { + let explanation = match fee_stats { + Some(stats) => explain_payment_with_fee(payment, fee_charged, stats), + None => explain_payment(payment), + }; + OperationExplanation { + index, + operation_type: "payment".to_string(), + summary: explanation.summary.clone(), + details: serde_json::json!({ + "from": explanation.from, + "to": explanation.to, + "amount": explanation.amount, + "asset": explanation.asset, + }), + } + } + Operation::CreateAccount(create_account) => { + let explanation = explain_create_account(create_account); + OperationExplanation { + index, + operation_type: "create_account".to_string(), + summary: explanation.summary.clone(), + details: serde_json::json!({ + "funder": explanation.funder, + "account": explanation.new_account, + "starting_balance": explanation.starting_balance, + }), + } + } + Operation::ChangeTrust(change_trust) => { + let explanation = explain_change_trust(change_trust); + OperationExplanation { + index, + operation_type: "change_trust".to_string(), + summary: explanation.summary.clone(), + details: serde_json::json!({ + "account": explanation.trustor, + "asset": explanation.asset_code, + "issuer": explanation.asset_issuer, + "limit": explanation.limit, + "is_removal": explanation.is_removal, + }), + } + } + Operation::SetOptions(set_options) => { + let explanation = explain_set_options(set_options); + OperationExplanation { + index, + operation_type: "set_options".to_string(), + summary: explanation.summary.clone(), + details: serde_json::json!({ + "account": explanation.account, + "changes": explanation.changes, + }), + } + } + Operation::AccountMerge(account_merge) => { + let explanation = explain_account_merge(account_merge); + OperationExplanation { + index, + operation_type: "account_merge".to_string(), + summary: explanation.summary.clone(), + details: serde_json::json!({ + "source": explanation.source, + "destination": explanation.destination, + }), + } + } + Operation::ManageOffer(manage_offer) => { + let explanation = explain_manage_offer(manage_offer); + let operation_type = match manage_offer.offer_type { + OfferType::Sell => "manage_sell_offer", + OfferType::Buy => "manage_buy_offer", + }; + OperationExplanation { + index, + operation_type: operation_type.to_string(), + summary: explanation.summary.clone(), + details: serde_json::json!({ + "account": explanation.seller, + "selling_asset": explanation.selling_asset, + "buying_asset": explanation.buying_asset, + "amount": explanation.amount, + "price": explanation.price, + "offer_id": explanation.offer_id, + "action": explanation.action, + }), + } + } + Operation::PathPayment(path_payment) => { + let explanation = explain_path_payment(path_payment); + let operation_type = match path_payment.payment_type { + PathPaymentType::StrictSend => "path_payment_strict_send", + PathPaymentType::StrictReceive => "path_payment_strict_receive", + }; + OperationExplanation { + index, + operation_type: operation_type.to_string(), + summary: explanation.summary.clone(), + details: serde_json::json!({ + "from": explanation.sender, + "to": explanation.destination, + "send_asset": explanation.send_asset, + "send_amount": explanation.send_amount, + "dest_asset": explanation.dest_asset, + "dest_amount": explanation.dest_amount, + "path_description": explanation.path_description, + }), + } + } + Operation::Clawback(clawback) => { + let explanation = explain_clawback(clawback); + OperationExplanation { + index, + operation_type: "clawback".to_string(), + summary: explanation.summary.clone(), + details: serde_json::json!({ + "issuer": explanation.issuer, + "from": explanation.from, + "asset": explanation.asset_code, + "asset_issuer": explanation.asset_issuer, + "amount": explanation.amount, + }), + } + } + Operation::ClawbackClaimableBalance(clawback_balance) => { + let explanation = explain_clawback_claimable_balance(clawback_balance); + OperationExplanation { + index, + operation_type: "clawback_claimable_balance".to_string(), + summary: explanation.summary.clone(), + details: serde_json::json!({ + "issuer": explanation.issuer, + "balance_id": explanation.balance_id, + }), + } + } + Operation::Other(other) => OperationExplanation { + index, + operation_type: other.operation_type.clone(), + summary: format!( + "{} operation — full support coming soon", + other.operation_type + ), + details: serde_json::json!({}), + }, + } +} + fn build_transaction_summary(successful: bool, payment_count: usize, skipped: usize) -> String { let status = if successful { "successful" } else { "failed" }; @@ -410,4 +625,149 @@ mod tests { let summary = build_transaction_summary(false, 1, 0); assert_eq!(summary, "This failed transaction contains 1 payment."); } -} + + // ── operations[] array ───────────────────────────────────────────────── + + fn create_account_op(id: &str) -> Operation { + Operation::CreateAccount(crate::models::operation::CreateAccountOperation { + id: id.to_string(), + funder: "GFUNDER".to_string(), + new_account: "GNEWACCT".to_string(), + starting_balance: "1.5".to_string(), + }) + } + + fn change_trust_op(id: &str) -> Operation { + Operation::ChangeTrust(crate::models::operation::ChangeTrustOperation { + id: id.to_string(), + trustor: "GTRUSTOR".to_string(), + asset_code: "USDC".to_string(), + asset_issuer: "GISSUER".to_string(), + limit: "10000".to_string(), + }) + } + + fn truly_unsupported_op(id: &str) -> Operation { + Operation::Other(OtherOperation { + id: id.to_string(), + operation_type: "bump_sequence".to_string(), + }) + } + + #[test] + fn test_operations_array_present_with_correct_index_and_type() { + let tx = Transaction { + operations: vec![ + create_account_op("1"), + create_payment_operation("2", "100"), + change_trust_op("3"), + ], + ..base_tx() + }; + let result = explain_transaction(&tx, None).unwrap(); + + assert_eq!(result.operations.len(), 3); + assert_eq!(result.operations[0].index, 0); + assert_eq!(result.operations[0].operation_type, "create_account"); + assert_eq!(result.operations[1].index, 1); + assert_eq!(result.operations[1].operation_type, "payment"); + assert_eq!(result.operations[2].index, 2); + assert_eq!(result.operations[2].operation_type, "change_trust"); + } + + #[test] + fn test_operations_preserve_original_order() { + let tx = Transaction { + operations: vec![ + change_trust_op("1"), + create_account_op("2"), + create_payment_operation("3", "50"), + ], + ..base_tx() + }; + let result = explain_transaction(&tx, None).unwrap(); + + let types: Vec<&str> = result + .operations + .iter() + .map(|op| op.operation_type.as_str()) + .collect(); + assert_eq!(types, vec!["change_trust", "create_account", "payment"]); + } + + #[test] + fn test_fully_supported_transaction_has_zero_skipped() { + let tx = Transaction { + operations: vec![ + create_account_op("1"), + create_payment_operation("2", "100"), + change_trust_op("3"), + ], + ..base_tx() + }; + let result = explain_transaction(&tx, None).unwrap(); + assert_eq!(result.skipped_operations, 0); + } + + #[test] + fn test_unknown_operation_type_graceful_fallback() { + let tx = Transaction { + operations: vec![truly_unsupported_op("1")], + ..base_tx() + }; + let result = explain_transaction(&tx, None).unwrap(); + + assert_eq!(result.operations.len(), 1); + assert_eq!(result.operations[0].operation_type, "bump_sequence"); + assert_eq!( + result.operations[0].summary, + "bump_sequence operation — full support coming soon" + ); + assert_ne!(result.operations[0].summary, ""); + assert_eq!(result.skipped_operations, 1); + } + + #[test] + fn test_payment_explanations_still_populated_for_backward_compat() { + let tx = Transaction { + operations: vec![ + create_account_op("1"), + create_payment_operation("2", "100"), + ], + ..base_tx() + }; + let result = explain_transaction(&tx, None).unwrap(); + + assert_eq!(result.payment_explanations.len(), 1); + assert_eq!(result.payment_explanations[0].amount, "100"); + } + + #[test] + fn test_operation_details_present_for_create_account() { + let tx = Transaction { + operations: vec![create_account_op("1")], + ..base_tx() + }; + let result = explain_transaction(&tx, None).unwrap(); + let details = &result.operations[0].details; + + assert_eq!(details["funder"], "GFUNDER"); + assert_eq!(details["account"], "GNEWACCT"); + assert_eq!(details["starting_balance"], "1.5"); + } + + #[test] + fn test_mixed_transaction_with_unsupported_type_counts_only_that_one() { + let tx = Transaction { + operations: vec![ + create_payment_operation("1", "10"), + truly_unsupported_op("2"), + create_account_op("3"), + ], + ..base_tx() + }; + let result = explain_transaction(&tx, None).unwrap(); + assert_eq!(result.operations.len(), 3); + assert_eq!(result.skipped_operations, 1); + } +} \ No newline at end of file diff --git a/packages/core/src/models/operation.rs b/packages/core/src/models/operation.rs index b186227..43b6e93 100644 --- a/packages/core/src/models/operation.rs +++ b/packages/core/src/models/operation.rs @@ -1,7 +1,3 @@ -//! Operation domain models. -//! -//! Internal representation of Stellar operations, independent of Horizon JSON. - use crate::models::memo::Memo; use serde::{Deserialize, Serialize}; @@ -26,6 +22,7 @@ pub enum Operation { PathPayment(PathPaymentOperation), Clawback(ClawbackOperation), ClawbackClaimableBalance(ClawbackClaimableBalanceOperation), + AccountMerge(AccountMergeOperation), Other(OtherOperation), } @@ -147,6 +144,17 @@ pub struct ClawbackClaimableBalanceOperation { pub balance_id: String, } +/// An account_merge operation that merges one account into another, +/// transferring all remaining XLM and removing the source account. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AccountMergeOperation { + pub id: String, + /// The account being merged away (the source). + pub source: String, + /// The account receiving the remaining XLM balance. + pub destination: String, +} + /// Placeholder for operation types we do not yet explain. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct OtherOperation { @@ -173,6 +181,7 @@ impl Operation { Operation::PathPayment(p) => &p.id, Operation::Clawback(c) => &c.id, Operation::ClawbackClaimableBalance(c) => &c.id, + Operation::AccountMerge(a) => &a.id, Operation::Other(o) => &o.id, } } @@ -346,6 +355,15 @@ impl From for Operation { balance_id: op.balance_id.unwrap_or_default(), }) } + "account_merge" => Operation::AccountMerge(AccountMergeOperation { + id: op.id, + source: op + .source_account + .clone() + .or(op.account.clone()) + .unwrap_or_else(|| "Unknown".to_string()), + destination: op.into.unwrap_or_default(), + }), _ => Operation::Other(OtherOperation { id: op.id, operation_type: op.operation_type, @@ -434,6 +452,16 @@ mod tests { assert_eq!(op.id(), "ct-1"); } + #[test] + fn test_account_merge_id() { + let op = Operation::AccountMerge(AccountMergeOperation { + id: "am-1".to_string(), + source: "GSOURCE".to_string(), + destination: "GDEST".to_string(), + }); + assert_eq!(op.id(), "am-1"); + } + #[test] fn test_format_asset_native() { let result = format_asset(Some("native"), None, None); @@ -445,4 +473,4 @@ mod tests { let result = format_asset(Some("credit_alphanum4"), Some("USDC"), Some("GISSUER")); assert_eq!(result, "USDC (GISSUER)"); } -} +} \ No newline at end of file From 853b77c085ee2aa05ea294b334f325706d77053c Mon Sep 17 00:00:00 2001 From: phertyameen Date: Sat, 20 Jun 2026 13:12:55 +0100 Subject: [PATCH 2/5] fix: format --- packages/core/src/explain/operation/account_merge.rs | 8 ++++++-- packages/core/src/explain/transaction.rs | 9 +++------ packages/core/src/models/operation.rs | 2 +- packages/core/src/services/horizon.rs | 2 ++ 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/core/src/explain/operation/account_merge.rs b/packages/core/src/explain/operation/account_merge.rs index a7574ee..09fe343 100644 --- a/packages/core/src/explain/operation/account_merge.rs +++ b/packages/core/src/explain/operation/account_merge.rs @@ -73,7 +73,11 @@ mod tests { fn test_explain_account_merge_mentions_transfer() { let op = make_account_merge("GAAAA", "GBBBB"); let explanation = explain_account_merge(&op); - assert!(explanation.summary.contains("transferring all remaining XLM")); + assert!( + explanation + .summary + .contains("transferring all remaining XLM") + ); } #[test] @@ -83,4 +87,4 @@ mod tests { assert_eq!(explanation.source, "Unknown"); assert!(explanation.summary.starts_with("Unknown merged")); } -} \ No newline at end of file +} diff --git a/packages/core/src/explain/transaction.rs b/packages/core/src/explain/transaction.rs index 19691d8..ab43d8a 100644 --- a/packages/core/src/explain/transaction.rs +++ b/packages/core/src/explain/transaction.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use crate::explain::memo::explain_memo; use crate::models::fee::FeeStats; -use crate::models::operation::{Operation, OfferType, PathPaymentType}; +use crate::models::operation::{OfferType, Operation, PathPaymentType}; use crate::models::transaction::Transaction; use super::operation::account_merge::explain_account_merge; @@ -730,10 +730,7 @@ mod tests { #[test] fn test_payment_explanations_still_populated_for_backward_compat() { let tx = Transaction { - operations: vec![ - create_account_op("1"), - create_payment_operation("2", "100"), - ], + operations: vec![create_account_op("1"), create_payment_operation("2", "100")], ..base_tx() }; let result = explain_transaction(&tx, None).unwrap(); @@ -770,4 +767,4 @@ mod tests { assert_eq!(result.operations.len(), 3); assert_eq!(result.skipped_operations, 1); } -} \ No newline at end of file +} diff --git a/packages/core/src/models/operation.rs b/packages/core/src/models/operation.rs index 43b6e93..2a79989 100644 --- a/packages/core/src/models/operation.rs +++ b/packages/core/src/models/operation.rs @@ -473,4 +473,4 @@ mod tests { let result = format_asset(Some("credit_alphanum4"), Some("USDC"), Some("GISSUER")); assert_eq!(result, "USDC (GISSUER)"); } -} \ No newline at end of file +} diff --git a/packages/core/src/services/horizon.rs b/packages/core/src/services/horizon.rs index 2eaf469..d1f020b 100644 --- a/packages/core/src/services/horizon.rs +++ b/packages/core/src/services/horizon.rs @@ -380,6 +380,8 @@ pub struct HorizonOperation { pub source_asset_issuer: Option, // Clawback fields pub balance_id: Option, + // Account merge fields + pub into: Option, } #[derive(Debug, Deserialize)] From 575ee2cb2d7cb8d893bfa1e033edeed1438945cf Mon Sep 17 00:00:00 2001 From: phertyameen Date: Sat, 20 Jun 2026 13:31:22 +0100 Subject: [PATCH 3/5] fix: test --- packages/core/src/explain/transaction.rs | 38 ++++++------------------ 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/packages/core/src/explain/transaction.rs b/packages/core/src/explain/transaction.rs index ab43d8a..fb25911 100644 --- a/packages/core/src/explain/transaction.rs +++ b/packages/core/src/explain/transaction.rs @@ -11,7 +11,7 @@ use super::operation::clawback::{explain_clawback, explain_clawback_claimable_ba use super::operation::create_account::explain_create_account; use super::operation::manage_offer::explain_manage_offer; use super::operation::path_payment::explain_path_payment; -use super::operation::payment::{PaymentExplanation, explain_payment, explain_payment_with_fee}; +use super::operation::payment::{explain_payment, explain_payment_with_fee, PaymentExplanation}; use super::operation::set_options::explain_set_options; /// A single explained operation within a transaction, in original order. @@ -164,9 +164,10 @@ pub fn explain_transaction_with_ledger( .map(|(index, op)| explain_operation(index, op, transaction.fee_charged, fee_stats)) .collect(); - let skipped_operations = operations + let skipped_operations = transaction + .operations .iter() - .filter(|op| is_unsupported_type(&op.operation_type)) + .filter(|op| matches!(op, Operation::Other(_))) .count(); let base_summary = @@ -207,25 +208,6 @@ pub fn explain_transaction_with_ledger( }) } -/// True for operation type strings that have no dedicated explainer and -/// fall back to the generic "coming soon" message. -fn is_unsupported_type(operation_type: &str) -> bool { - !matches!( - operation_type, - "payment" - | "create_account" - | "change_trust" - | "set_options" - | "account_merge" - | "manage_sell_offer" - | "manage_buy_offer" - | "path_payment_strict_send" - | "path_payment_strict_receive" - | "clawback" - | "clawback_claimable_balance" - ) -} - /// Build the structured explanation for a single operation, preserving its /// position within the transaction. fn explain_operation( @@ -592,12 +574,10 @@ mod tests { }; let explanation = explain_transaction(&tx, None).unwrap(); assert!(explanation.memo_explanation.is_some()); - assert!( - explanation - .memo_explanation - .unwrap() - .contains("Invoice #12345") - ); + assert!(explanation + .memo_explanation + .unwrap() + .contains("Invoice #12345")); } #[test] @@ -767,4 +747,4 @@ mod tests { assert_eq!(result.operations.len(), 3); assert_eq!(result.skipped_operations, 1); } -} +} \ No newline at end of file From 7bfaacb1d07b84e4e80b6cd93925214bdd3fcb1a Mon Sep 17 00:00:00 2001 From: phertyameen Date: Sun, 21 Jun 2026 18:16:12 +0100 Subject: [PATCH 4/5] fix: lint --- .../cli/tests/formatters/transaction.test.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/packages/cli/tests/formatters/transaction.test.ts b/packages/cli/tests/formatters/transaction.test.ts index 0e46bfa..3dbbba2 100644 --- a/packages/cli/tests/formatters/transaction.test.ts +++ b/packages/cli/tests/formatters/transaction.test.ts @@ -29,24 +29,6 @@ describe("formatTransaction", () => { expect(out).toContain("Memo: hello"); }); - it("renders payments as a table with aligned columns", () => { - const tx: TransactionExplanation = { - ...baseTx, - payments: [ - { from: "GAAA", to: "GBBB", amount: "10.00", asset: "XLM", summary: "" }, - { from: "GCCCCCCCC", to: "GD", amount: "9999.00", asset: "USDC", summary: "" }, - ], - }; - const out = formatTransaction(tx); - expect(out).toContain("Payments:"); - const lines = out.split("\n").filter((l) => l.startsWith(" ") && l.includes("→")); - expect(lines).toHaveLength(2); - // columns must be aligned — each line same length up to asset start - const arrowIdx0 = lines[0].indexOf("→"); - const arrowIdx1 = lines[1].indexOf("→"); - expect(arrowIdx0).toBe(arrowIdx1); - }); - it("renders skipped_operations when > 0", () => { const out = formatTransaction({ ...baseTx, skipped_operations: 2 }); expect(out).toContain("Skipped ops: 2"); From 8505b26221f0c2fe767f7ce9205460ccb5633d60 Mon Sep 17 00:00:00 2001 From: phertyameen Date: Sun, 21 Jun 2026 18:22:56 +0100 Subject: [PATCH 5/5] fix: lint --- packages/core/src/explain/transaction.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/core/src/explain/transaction.rs b/packages/core/src/explain/transaction.rs index 5b34b05..1ca0a1f 100644 --- a/packages/core/src/explain/transaction.rs +++ b/packages/core/src/explain/transaction.rs @@ -12,7 +12,7 @@ use super::operation::clawback::{explain_clawback, explain_clawback_claimable_ba use super::operation::create_account::explain_create_account; use super::operation::manage_offer::explain_manage_offer; use super::operation::path_payment::explain_path_payment; -use super::operation::payment::{explain_payment, explain_payment_with_fee, PaymentExplanation}; +use super::operation::payment::{PaymentExplanation, explain_payment, explain_payment_with_fee}; use super::operation::set_options::explain_set_options; /// A single explained operation within a transaction, in original order. @@ -591,10 +591,12 @@ mod tests { }; let explanation = explain_transaction(&tx, None).unwrap(); assert!(explanation.memo_explanation.is_some()); - assert!(explanation - .memo_explanation - .unwrap() - .contains("Invoice #12345")); + assert!( + explanation + .memo_explanation + .unwrap() + .contains("Invoice #12345") + ); } #[test]