From 6b1d398c44db6d1982f33ef8ff22ce797384d80b Mon Sep 17 00:00:00 2001 From: demilade18-git Date: Thu, 26 Feb 2026 09:53:44 +0100 Subject: [PATCH] feat(explain): enrich transaction explanations with ledger timeline context --- packages/core/src/explain/transaction.rs | 230 +++++++++++++++++++++-- packages/core/src/routes/tx.rs | 19 +- packages/core/src/services/horizon.rs | 4 + 3 files changed, 227 insertions(+), 26 deletions(-) diff --git a/packages/core/src/explain/transaction.rs b/packages/core/src/explain/transaction.rs index a30fa3d..8aeb003 100644 --- a/packages/core/src/explain/transaction.rs +++ b/packages/core/src/explain/transaction.rs @@ -16,8 +16,14 @@ pub struct TransactionExplanation { pub summary: String, pub payment_explanations: Vec, pub skipped_operations: usize, + /// Human-readable explanation of the transaction memo. pub memo_explanation: Option, + /// Human-readable explanation of transaction fee context. pub fee_explanation: Option, + /// ISO 8601 timestamp of when the ledger closed (from Horizon). + pub ledger_closed_at: Option, + /// Ledger sequence number this transaction was included in. + pub ledger: Option, } pub type ExplainResult = Result; @@ -39,6 +45,44 @@ impl std::fmt::Display for ExplainError { impl std::error::Error for ExplainError {} +/// Format an ISO 8601 timestamp from Horizon into a human-readable string. +/// +/// Input: "2024-01-15T14:32:00Z" +/// Output: "2024-01-15 at 14:32 UTC" +/// +/// Returns the original string unchanged if it cannot be parsed. +pub fn format_ledger_time(iso_string: &str) -> String { + // Expected format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DDTHH:MM:SS+00:00 + // We split on 'T' to separate date and time parts. + let s = iso_string.trim(); + + let t_pos = match s.find('T') { + Some(pos) => pos, + None => return iso_string.to_string(), + }; + + let date = &s[..t_pos]; + let time_part = &s[t_pos + 1..]; + + // Strip timezone suffix: trailing Z, +00:00, or similar + let time = if let Some(z_pos) = time_part.find('Z') { + &time_part[..z_pos] + } else if let Some(plus_pos) = time_part.find('+') { + &time_part[..plus_pos] + } else if let Some(minus_pos) = time_part[1..].find('-').map(|p| p + 1) { + // Only strip trailing timezone offset (not the date hyphens) + // time_part looks like "14:32:00-05:00" + &time_part[..minus_pos] + } else { + time_part + }; + + // Take only HH:MM (drop seconds) + let hhmm = if time.len() >= 5 { &time[..5] } else { time }; + + format!("{} at {} UTC", date, hhmm) +} + /// Produce a plain-English fee explanation. pub fn explain_fee(fee_charged: u64, fee_stats: Option<&FeeStats>) -> String { let xlm = FeeStats::stroops_to_xlm(fee_charged); @@ -61,6 +105,16 @@ pub fn explain_fee(fee_charged: u64, fee_stats: Option<&FeeStats>) -> String { pub fn explain_transaction( transaction: &Transaction, fee_stats: Option<&FeeStats>, +) -> ExplainResult { + explain_transaction_with_ledger(transaction, fee_stats, None, None) +} + +/// Full explanation with optional ledger time enrichment. +pub fn explain_transaction_with_ledger( + transaction: &Transaction, + fee_stats: Option<&FeeStats>, + created_at: Option<&str>, + ledger: Option, ) -> ExplainResult { let total_operations = transaction.operations.len(); @@ -80,7 +134,27 @@ pub fn explain_transaction( }) .collect::>(); - let summary = build_transaction_summary(transaction.successful, payment_count, skipped_operations); + let base_summary = build_transaction_summary(transaction.successful, payment_count, skipped_operations); + + // Enrich summary with ledger time if available + let summary = match (created_at, ledger) { + (Some(ts), Some(seq)) => { + let formatted_time = format_ledger_time(ts); + format!( + "{} This transaction was confirmed on {} (ledger #{}).", + base_summary, formatted_time, seq + ) + } + (Some(ts), None) => { + let formatted_time = format_ledger_time(ts); + format!("{} This transaction was confirmed on {}.", base_summary, formatted_time) + } + (None, Some(seq)) => { + format!("{} Included in ledger #{}.", base_summary, seq) + } + (None, None) => base_summary, + }; + let memo_explanation = transaction.memo.as_ref().and_then(explain_memo); let fee_explanation = Some(explain_fee(transaction.fee_charged, fee_stats)); @@ -92,6 +166,8 @@ pub fn explain_transaction( skipped_operations, memo_explanation, fee_explanation, + ledger_closed_at: created_at.map(|s| s.to_string()), + ledger, }) } @@ -151,6 +227,133 @@ mod tests { }) } + fn base_tx() -> Transaction { + Transaction { + hash: "abc123".to_string(), + successful: true, + fee_charged: 100, + operations: vec![create_payment_operation("1", "50.0")], + memo: None, + } + } + + // ── format_ledger_time ───────────────────────────────────────────────── + + #[test] + fn test_format_ledger_time_standard_utc() { + let result = format_ledger_time("2024-01-15T14:32:00Z"); + assert_eq!(result, "2024-01-15 at 14:32 UTC"); + } + + #[test] + fn test_format_ledger_time_midnight() { + let result = format_ledger_time("2024-06-01T00:00:00Z"); + assert_eq!(result, "2024-06-01 at 00:00 UTC"); + } + + #[test] + fn test_format_ledger_time_end_of_day() { + let result = format_ledger_time("2024-12-31T23:59:59Z"); + assert_eq!(result, "2024-12-31 at 23:59 UTC"); + } + + #[test] + fn test_format_ledger_time_with_positive_offset() { + // Some Horizon responses use +00:00 instead of Z + let result = format_ledger_time("2024-03-10T08:15:00+00:00"); + assert_eq!(result, "2024-03-10 at 08:15 UTC"); + } + + #[test] + fn test_format_ledger_time_strips_seconds() { + // Only HH:MM should appear in output + let result = format_ledger_time("2024-01-15T14:32:45Z"); + assert!(!result.contains(":45")); + assert!(result.contains("14:32")); + } + + #[test] + fn test_format_ledger_time_invalid_returns_original() { + let bad = "not-a-timestamp"; + let result = format_ledger_time(bad); + assert_eq!(result, bad); + } + + #[test] + fn test_format_ledger_time_empty_string() { + let result = format_ledger_time(""); + assert_eq!(result, ""); + } + + #[test] + fn test_format_ledger_time_date_only() { + // No T separator — should return original + let result = format_ledger_time("2024-01-15"); + assert_eq!(result, "2024-01-15"); + } + + #[test] + fn test_format_ledger_time_with_whitespace() { + let result = format_ledger_time(" 2024-01-15T14:32:00Z "); + assert_eq!(result, "2024-01-15 at 14:32 UTC"); + } + + // ── ledger enrichment ────────────────────────────────────────────────── + + #[test] + fn test_explain_transaction_with_both_ledger_fields() { + let result = explain_transaction_with_ledger( + &base_tx(), + None, + Some("2024-01-15T14:32:00Z"), + Some(49823145), + ) + .unwrap(); + + assert!(result.summary.contains("2024-01-15 at 14:32 UTC")); + assert!(result.summary.contains("ledger #49823145")); + assert_eq!(result.ledger_closed_at.as_deref(), Some("2024-01-15T14:32:00Z")); + assert_eq!(result.ledger, Some(49823145)); + } + + #[test] + fn test_explain_transaction_with_time_only() { + let result = explain_transaction_with_ledger( + &base_tx(), + None, + Some("2024-06-20T09:00:00Z"), + None, + ) + .unwrap(); + + assert!(result.summary.contains("2024-06-20 at 09:00 UTC")); + assert!(!result.summary.contains("ledger #")); + assert_eq!(result.ledger, None); + } + + #[test] + fn test_explain_transaction_with_ledger_only() { + let result = explain_transaction_with_ledger( + &base_tx(), + None, + None, + Some(12345678), + ) + .unwrap(); + + assert!(result.summary.contains("ledger #12345678")); + assert_eq!(result.ledger_closed_at, None); + } + + #[test] + fn test_explain_transaction_without_ledger_fields() { + let result = explain_transaction(&base_tx(), None).unwrap(); + assert_eq!(result.ledger_closed_at, None); + assert_eq!(result.ledger, None); + assert!(!result.summary.contains("confirmed on")); + assert!(!result.summary.contains("ledger #")); + } + #[test] fn test_explain_fee_standard() { let stats = FeeStats::new(100, 100, 5000, 100, 250); @@ -170,43 +373,30 @@ mod tests { #[test] fn test_explain_transaction_with_memo() { let tx = Transaction { - hash: "abc123".to_string(), - successful: true, - fee_charged: 100, - operations: vec![create_payment_operation("1", "50.0")], memo: Some(Memo::text("Invoice #12345").unwrap()), + ..base_tx() }; let explanation = explain_transaction(&tx, None).unwrap(); - assert_eq!(explanation.transaction_hash, "abc123"); assert!(explanation.memo_explanation.is_some()); assert!(explanation.memo_explanation.unwrap().contains("Invoice #12345")); - assert!(explanation.fee_explanation.is_some()); } #[test] fn test_explain_no_payments_returns_ok() { let tx = Transaction { - hash: "ghi789".to_string(), - successful: true, - fee_charged: 100, operations: vec![create_other_operation("1"), create_other_operation("2")], - memo: None, + ..base_tx() }; - let result = explain_transaction(&tx, None); - assert!(result.is_ok()); - let explanation = result.unwrap(); - assert_eq!(explanation.payment_explanations.len(), 0); - assert_eq!(explanation.skipped_operations, 2); + let result = explain_transaction(&tx, None).unwrap(); + assert_eq!(result.payment_explanations.len(), 0); + assert_eq!(result.skipped_operations, 2); } #[test] fn test_explain_empty_transaction_returns_err() { let tx = Transaction { - hash: "empty".to_string(), - successful: true, - fee_charged: 100, operations: vec![], - memo: None, + ..base_tx() }; assert!(explain_transaction(&tx, None).is_err()); } diff --git a/packages/core/src/routes/tx.rs b/packages/core/src/routes/tx.rs index ac1e70d..c95c32f 100644 --- a/packages/core/src/routes/tx.rs +++ b/packages/core/src/routes/tx.rs @@ -10,7 +10,7 @@ use tracing::{error, info, info_span}; use crate::{ errors::AppError, - explain::transaction::{explain_transaction, TransactionExplanation}, + explain::transaction::{explain_transaction_with_ledger, TransactionExplanation}, middleware::request_id::RequestId, services::{explain::map_transaction_to_domain, horizon::HorizonClient}, }; @@ -22,7 +22,6 @@ pub struct TxExplanationResponse { pub explanation: String, } - #[utoipa::path( get, path = "/tx/{hash}", @@ -122,12 +121,20 @@ pub async fn get_tx_explanation( } }; - // fee_stats is Option — None if Horizon /fee_stats is unavailable - // explain_transaction degrades gracefully when it is None + // Capture ledger fields before tx is consumed by map_transaction_to_domain + let created_at = tx.created_at.clone(); + let ledger = tx.ledger; + // fee_stats is Option — None if Horizon /fee_stats is unavailable let domain_tx = map_transaction_to_domain(tx, ops); let explain_started_at = Instant::now(); - let explanation = match explain_transaction(&domain_tx, fee_stats.as_ref()) { + + let explanation = match explain_transaction_with_ledger( + &domain_tx, + fee_stats.as_ref(), + created_at.as_deref(), + ledger, + ) { Ok(explanation) => explanation, Err(err) => { let app_error: AppError = err.into(); @@ -159,4 +166,4 @@ pub async fn get_tx_explanation( fn is_valid_transaction_hash(hash: &str) -> bool { hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) -} +} \ No newline at end of file diff --git a/packages/core/src/services/horizon.rs b/packages/core/src/services/horizon.rs index 136e959..af5d413 100644 --- a/packages/core/src/services/horizon.rs +++ b/packages/core/src/services/horizon.rs @@ -14,6 +14,10 @@ pub struct HorizonTransaction { pub fee_charged: String, pub memo_type: Option, pub memo: Option, + /// ISO 8601 timestamp of ledger close, e.g. "2024-01-15T14:32:00Z" + pub created_at: Option, + /// Ledger sequence number in which this transaction was included. + pub ledger: Option, } #[derive(Debug, Deserialize, Clone)]