Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 210 additions & 20 deletions packages/core/src/explain/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,14 @@ pub struct TransactionExplanation {
pub summary: String,
pub payment_explanations: Vec<PaymentExplanation>,
pub skipped_operations: usize,
/// Human-readable explanation of the transaction memo.
pub memo_explanation: Option<String>,
/// Human-readable explanation of transaction fee context.
pub fee_explanation: Option<String>,
/// ISO 8601 timestamp of when the ledger closed (from Horizon).
pub ledger_closed_at: Option<String>,
/// Ledger sequence number this transaction was included in.
pub ledger: Option<u64>,
}

pub type ExplainResult = Result<TransactionExplanation, ExplainError>;
Expand All @@ -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);
Expand All @@ -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<u64>,
) -> ExplainResult {
let total_operations = transaction.operations.len();

Expand All @@ -80,7 +134,27 @@ pub fn explain_transaction(
})
.collect::<Vec<_>>();

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));

Expand All @@ -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,
})
}

Expand Down Expand Up @@ -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);
Expand All @@ -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());
}
Expand Down
19 changes: 13 additions & 6 deletions packages/core/src/routes/tx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
Expand All @@ -22,7 +22,6 @@ pub struct TxExplanationResponse {
pub explanation: String,
}


#[utoipa::path(
get,
path = "/tx/{hash}",
Expand Down Expand Up @@ -122,12 +121,20 @@ pub async fn get_tx_explanation(
}
};

// fee_stats is Option<FeeStats> — 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<FeeStats> — 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();
Expand Down Expand Up @@ -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())
}
}
4 changes: 4 additions & 0 deletions packages/core/src/services/horizon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ pub struct HorizonTransaction {
pub fee_charged: String,
pub memo_type: Option<String>,
pub memo: Option<String>,
/// ISO 8601 timestamp of ledger close, e.g. "2024-01-15T14:32:00Z"
pub created_at: Option<String>,
/// Ledger sequence number in which this transaction was included.
pub ledger: Option<u64>,
}

#[derive(Debug, Deserialize, Clone)]
Expand Down
Loading