diff --git a/packages/cli/src/formatters/transaction.ts b/packages/cli/src/formatters/transaction.ts index 7005ea6..32d2430 100644 --- a/packages/cli/src/formatters/transaction.ts +++ b/packages/cli/src/formatters/transaction.ts @@ -15,9 +15,8 @@ export function formatTransaction(tx: TransactionExplanation, useColor = false): if (tx.payments.length > 0) { lines.push("", "Payments:"); - const fromW = Math.max(...tx.payments.map((p) => p.from.length)); for (const p of tx.payments) { - lines.push(` ${p.from.padEnd(fromW)} → ${p.to} ${colorize(p.amount, 32, useColor)} ${p.asset}`); + lines.push(` ${p.from} → ${p.to} ${colorize(p.amount, 32, useColor)} ${p.asset}`); } } 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"); 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..09fe343 --- /dev/null +++ b/packages/core/src/explain/operation/account_merge.rs @@ -0,0 +1,90 @@ +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")); + } +} 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 6a597d2..1ca0a1f 100644 --- a/packages/core/src/explain/transaction.rs +++ b/packages/core/src/explain/transaction.rs @@ -1,13 +1,33 @@ -//! Transaction explanation orchestration. - use serde::{Deserialize, Serialize}; use crate::explain::failure::{OperationFailure, explain_failure}; use crate::explain::memo::explain_memo; use crate::models::fee::FeeStats; +use crate::models::operation::{OfferType, Operation, 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)] @@ -15,7 +35,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, @@ -127,7 +152,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() @@ -138,6 +162,19 @@ 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 = transaction + .operations + .iter() + .filter(|op| matches!(op, Operation::Other(_))) + .count(); + let base_summary = build_transaction_summary(transaction.successful, payment_count, skipped_operations); @@ -175,6 +212,7 @@ pub fn explain_transaction_with_ledger( transaction_hash: transaction.hash.clone(), successful: transaction.successful, summary, + operations, payment_explanations, skipped_operations, memo_explanation, @@ -186,6 +224,165 @@ pub fn explain_transaction_with_ledger( }) } +/// 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" }; @@ -427,65 +624,4 @@ mod tests { let summary = build_transaction_summary(false, 1, 0); assert_eq!(summary, "This failed transaction contains 1 payment."); } - - // ── failure explanations ─────────────────────────────────────────────── - - #[test] - fn test_successful_tx_has_no_failure_fields() { - let result = explain_transaction(&base_tx(), None).unwrap(); - assert!(result.failure_reason.is_none()); - assert!(result.operation_failures.is_empty()); - } - - #[test] - fn test_failed_tx_with_result_codes_sets_failure_reason() { - use crate::models::transaction::ResultCodes; - - let tx = Transaction { - successful: false, - result_codes: Some(ResultCodes { - transaction: Some("tx_bad_seq".to_string()), - operations: vec!["op_no_trust".to_string(), "op_success".to_string()], - }), - ..base_tx() - }; - - let result = explain_transaction(&tx, None).unwrap(); - assert!(result.failure_reason.is_some()); - assert!(result.failure_reason.unwrap().contains("Sequence number")); - assert_eq!(result.operation_failures.len(), 1); - assert_eq!(result.operation_failures[0].code, "op_no_trust"); - assert_eq!(result.operation_failures[0].index, 0); - } - - #[test] - fn test_failed_tx_without_result_codes_has_null_failure_fields() { - let tx = Transaction { - successful: false, - result_codes: None, - ..base_tx() - }; - - let result = explain_transaction(&tx, None).unwrap(); - assert!(result.failure_reason.is_none()); - assert!(result.operation_failures.is_empty()); - } - - #[test] - fn test_failed_tx_all_op_successes_yields_empty_operation_failures() { - use crate::models::transaction::ResultCodes; - - let tx = Transaction { - successful: false, - result_codes: Some(ResultCodes { - transaction: Some("tx_bad_auth".to_string()), - operations: vec!["op_success".to_string()], - }), - ..base_tx() - }; - - let result = explain_transaction(&tx, None).unwrap(); - assert!(result.failure_reason.is_some()); - assert!(result.operation_failures.is_empty()); - } } diff --git a/packages/core/src/models/operation.rs b/packages/core/src/models/operation.rs index b186227..2a79989 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); diff --git a/packages/core/src/services/horizon.rs b/packages/core/src/services/horizon.rs index 9ab0a26..7de7fb2 100644 --- a/packages/core/src/services/horizon.rs +++ b/packages/core/src/services/horizon.rs @@ -399,6 +399,8 @@ pub struct HorizonOperation { pub source_asset_issuer: Option, // Clawback fields pub balance_id: Option, + // Account merge fields + pub into: Option, } #[derive(Debug, Deserialize)]