From 473549075925a59f7a2c1e5e0697ec8e4607ab0f Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 12 Jun 2026 11:47:23 +0200 Subject: [PATCH] feat(app): transaction detail overlay with full on-chain data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Double-clicking a history row opens an in-app overlay (same pattern as address/server/settings overlays) that shows all available data for a transaction. The overlay appears immediately with a spinner; data is fetched from the server in the background and rendered once ready. Backdrop tap and Esc close it; a pending fetch is cancelled. New Core type — TransactionInspector (Core/Wallet/): - FetchAsync() downloads the raw tx, all parent txs (parallel requests, one round-trip instead of N sequential), and the block header - Reconstructs inputs (amounts, addresses, is-mine), outputs, fee, virtual size, RBF flag, block timestamp, confirmations New App types: - TransactionDetails record: full parsed data (fee, net, I/O lists, counterparty addresses, coinbase detection…) - TransactionDetailsViewModel: pre-formats all strings in the current locale and unit (status, date, signed amounts, sat/vB rate…) - TxIoRow record: one row in the input/output tables - MineColorConverter: bool → brush (MediumSeaGreen for own addresses) ViewModel changes: - ShowTransactionDetailsAsync / CloseTransactionDetailsCommand - BuildTransactionDetailsAsync (also public for future RBF/CPFP use) - CancellationTokenSource so opening a new tx cancels the previous fetch - All server work runs on Task.Run to keep the UI thread free XAML: history rows get DoubleTapped handler, Hand cursor, hint label, txid now uses TextTrimming instead of SelectableTextBlock. i18n: 24 new tx.* / history.hint keys in all 6 languages. --- src/App/Localization/Loc.cs | 29 +++ src/App/ViewModels/MainWindowViewModel.cs | 117 +++++++++++ src/App/ViewModels/MineColorConverter.cs | 22 ++ .../ViewModels/TransactionDetailsViewModel.cs | 112 ++++++++++ src/App/Views/MainWindow.axaml | 156 ++++++++++++-- src/App/Views/MainWindow.axaml.cs | 18 ++ src/Core/Wallet/TransactionInspector.cs | 192 ++++++++++++++++++ 7 files changed, 633 insertions(+), 13 deletions(-) create mode 100644 src/App/ViewModels/MineColorConverter.cs create mode 100644 src/App/ViewModels/TransactionDetailsViewModel.cs create mode 100644 src/Core/Wallet/TransactionInspector.cs diff --git a/src/App/Localization/Loc.cs b/src/App/Localization/Loc.cs index 695ec1f..998c057 100644 --- a/src/App/Localization/Loc.cs +++ b/src/App/Localization/Loc.cs @@ -138,6 +138,35 @@ public sealed class Loc ["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld"], ["addr.info.title"] = ["Informazioni indirizzo", "Address information", "Información de dirección", "Informations sur l'adresse", "Informações do endereço", "Adressinformationen"], ["addr.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"], + + // Storico → dettaglio transazione + ["history.hint"] = ["Doppio click su una transazione per i dettagli.", "Double-click a transaction for details.", "Doble clic en una transacción para ver los detalles.", "Double-cliquez sur une transaction pour les détails.", "Clique duplo numa transação para ver os detalhes.", "Doppelklick auf eine Transaktion für Details."], + ["tx.title"] = ["Dettagli transazione", "Transaction details", "Detalles de la transacción", "Détails de la transaction", "Detalhes da transação", "Transaktionsdetails"], + ["tx.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"], + ["tx.loading"] = ["Carico i dati della transazione dal server…", "Loading transaction data from the server…", "Cargando los datos de la transacción desde el servidor…", "Chargement des données de la transaction depuis le serveur…", "Carregando os dados da transação do servidor…", "Lade Transaktionsdaten vom Server…"], + ["tx.status"] = ["Stato", "Status", "Estado", "Statut", "Estado", "Status"], + ["tx.status.mempool"] = ["0 conferme · in mempool", "0 confirmations · in mempool", "0 confirmaciones · en mempool", "0 confirmation · dans le mempool", "0 confirmações · no mempool", "0 Bestätigungen · im Mempool"], + ["tx.status.confirmations"] = ["conferme", "confirmations", "confirmaciones", "confirmations", "confirmações", "Bestätigungen"], + ["tx.status.block"] = ["blocco", "block", "bloque", "bloc", "bloco", "Block"], + ["tx.mempool"] = ["in mempool", "in mempool", "en mempool", "dans le mempool", "no mempool", "im Mempool"], + ["tx.date"] = ["Data", "Date", "Fecha", "Date", "Data", "Datum"], + ["tx.to"] = ["A", "To", "Para", "À", "Para", "An"], + ["tx.from"] = ["Da", "From", "De", "De", "De", "Von"], + ["tx.debit"] = ["Debito", "Debit", "Débito", "Débit", "Débito", "Soll"], + ["tx.credit"] = ["Credito", "Credit", "Crédito", "Crédit", "Crédito", "Haben"], + ["tx.fee"] = ["Fee transazione", "Transaction fee", "Comisión de transacción", "Frais de transaction", "Taxa da transação", "Transaktionsgebühr"], + ["tx.feerate"] = ["Fee per vByte", "Fee per vByte", "Comisión por vByte", "Frais par vOctet", "Taxa por vByte", "Gebühr pro vByte"], + ["tx.net"] = ["Importo netto", "Net amount", "Importe neto", "Montant net", "Valor líquido", "Nettobetrag"], + ["tx.id"] = ["ID transazione", "Transaction ID", "ID de transacción", "ID de transaction", "ID da transação", "Transaktions-ID"], + ["tx.size.total"] = ["Dimensione totale", "Total size", "Tamaño total", "Taille totale", "Tamanho total", "Gesamtgröße"], + ["tx.size.virtual"] = ["Dimensione virtuale", "Virtual size", "Tamaño virtual", "Taille virtuelle", "Tamanho virtual", "Virtuelle Größe"], + ["tx.rbf"] = ["Sostituibile (RBF)", "Replaceable (RBF)", "Reemplazable (RBF)", "Remplaçable (RBF)", "Substituível (RBF)", "Ersetzbar (RBF)"], + ["tx.verified"] = ["Verifica SPV", "SPV verification", "Verificación SPV", "Vérification SPV", "Verificação SPV", "SPV-Prüfung"], + ["tx.inputs"] = ["Input", "Inputs", "Entradas", "Entrées", "Entradas", "Eingänge"], + ["tx.outputs"] = ["Output", "Outputs", "Salidas", "Sorties", "Saídas", "Ausgänge"], + ["tx.yes"] = ["Sì", "Yes", "Sí", "Oui", "Sim", "Ja"], + ["tx.no"] = ["No", "No", "No", "Non", "Não", "Nein"], + ["tx.needconnection"] = ["Connettiti al server per vedere i dettagli della transazione.", "Connect to the server to view transaction details.", "Conéctate al servidor para ver los detalles de la transacción.", "Connectez-vous au serveur pour voir les détails de la transaction.", "Conecte-se ao servidor para ver os detalhes da transação.", "Mit dem Server verbinden, um die Transaktionsdetails zu sehen."], ["send.from.contact"] = ["Da contatti:", "From contacts:", "De contactos:", "Depuis les contacts :", "De contatos:", "Aus Kontakten:"], ["send.contact.hint"] = ["seleziona per riempire l'indirizzo", "select to fill address", "selecciona para rellenar la dirección", "sélectionner pour remplir l'adresse", "selecione para preencher o endereço", "auswählen um Adresse einzufügen"], ["send.to"] = ["Indirizzo destinatario", "Recipient address", "Dirección destinataria", "Adresse du destinataire", "Endereço do destinatário", "Empfängeradresse"], diff --git a/src/App/ViewModels/MainWindowViewModel.cs b/src/App/ViewModels/MainWindowViewModel.cs index 642da67..fc80363 100644 --- a/src/App/ViewModels/MainWindowViewModel.cs +++ b/src/App/ViewModels/MainWindowViewModel.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Avalonia.Media.Imaging; using Avalonia.Threading; @@ -345,6 +346,122 @@ public partial class MainWindowViewModel : ViewModelBase [RelayCommand] private void CloseAddressInfo() => AddressInfo = null; + // ---- overlay dettaglio transazione ---- + + /// Overlay dettaglio transazione aperto. + [ObservableProperty] + private bool isTxDetailsOpen; + + /// Caricamento dei dati in corso (mostra lo spinner nell'overlay). + [ObservableProperty] + private bool isTxDetailsLoading; + + /// Dati della transazione mostrata; null finché non sono pronti. + [ObservableProperty] + private TransactionDetailsViewModel? txDetails; + + private CancellationTokenSource? _txDetailsCts; + + /// + /// Apre l'overlay dettaglio transazione: appare subito con lo spinner e i + /// dati arrivano dal server in background. Overlay in-app (come indirizzo e + /// impostazioni) per apertura/chiusura istantanee, senza una top-level window. + /// + public async Task ShowTransactionDetailsAsync(string txid) + { + if (_client is null || !_client.IsConnected) + { + StatusMessage = Loc.Tr("tx.needconnection"); + return; + } + + _txDetailsCts?.Cancel(); + _txDetailsCts = new CancellationTokenSource(); + var ct = _txDetailsCts.Token; + + TxDetails = null; + IsTxDetailsLoading = true; + IsTxDetailsOpen = true; + + var details = await BuildTransactionDetailsAsync(txid, ct); + + // L'utente potrebbe aver chiuso l'overlay (o aperto un'altra tx) mentre + // caricava: non sovrascrivere lo stato in quel caso. + if (ct.IsCancellationRequested || !IsTxDetailsOpen) + return; + + if (details is null) + { + IsTxDetailsOpen = false; + IsTxDetailsLoading = false; + return; + } + + TxDetails = details; + IsTxDetailsLoading = false; + } + + [RelayCommand] + private void CloseTransactionDetails() + { + _txDetailsCts?.Cancel(); + IsTxDetailsOpen = false; + IsTxDetailsLoading = false; + TxDetails = null; + } + + /// + /// Recupera dal server tutti i dati della transazione + /// (importi, fee, indirizzi, dimensioni, data) e li impacchetta per la + /// finestra di dettaglio. Restituisce null se non c'è connessione o in errore. + /// + public async Task BuildTransactionDetailsAsync( + string txid, CancellationToken ct = default) + { + if (_client is null || !_client.IsConnected) + { + StatusMessage = Loc.Tr("tx.needconnection"); + return null; + } + if (_doc?.Cache is not { } cache) + return null; + + // Snapshot dei dati sull'UI thread, poi tutto il lavoro (round-trip al + // server + parsing delle transazioni) va su un thread pool: altrimenti i + // continuation dei fetch riprendono sull'UI thread e bloccano la finestra + // (spinner fermo, chiusura ritardata). + var client = _client; + var network = PalladiumNetworks.For(Net); + var row = cache.History.FirstOrDefault(t => t.Txid == txid); + var owned = cache.Addresses.Select(a => a.Address).ToHashSet(); + var tipHeight = cache.TipHeight; + var height = row?.Height ?? 0; + var delta = row?.DeltaSats ?? 0; + var verified = row?.Verified ?? false; + var transactions = _lastTransactions; + var loc = _loc; + var unit = _config.Unit; + + try + { + return await Task.Run(async () => + { + var details = await TransactionInspector.FetchAsync( + client, network, txid, tipHeight, height, owned, delta, verified, transactions, ct); + return new TransactionDetailsViewModel(details, loc, unit); + }, ct); + } + catch (OperationCanceledException) + { + return null; + } + catch (Exception ex) + { + StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}"; + return null; + } + } + // ---- rubrica contatti ---- public ObservableCollection Contacts { get; } = []; diff --git a/src/App/ViewModels/MineColorConverter.cs b/src/App/ViewModels/MineColorConverter.cs new file mode 100644 index 0000000..8de4eb7 --- /dev/null +++ b/src/App/ViewModels/MineColorConverter.cs @@ -0,0 +1,22 @@ +using System; +using System.Globalization; +using Avalonia; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace PalladiumWallet.App.ViewModels; + +/// +/// bool → pennello: gli indirizzi del wallet (input/output "nostri") sono +/// evidenziati in verde, gli altri usano il colore di testo predefinito. +/// +public sealed class MineColorConverter : IValueConverter +{ + public static readonly MineColorConverter Instance = new(); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + value is true ? Brushes.MediumSeaGreen : AvaloniaProperty.UnsetValue; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/src/App/ViewModels/TransactionDetailsViewModel.cs b/src/App/ViewModels/TransactionDetailsViewModel.cs new file mode 100644 index 0000000..90f180c --- /dev/null +++ b/src/App/ViewModels/TransactionDetailsViewModel.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using PalladiumWallet.App.Localization; +using PalladiumWallet.Core.Wallet; + +namespace PalladiumWallet.App.ViewModels; + +/// Riga input/output per le tabelle della finestra di dettaglio. +public sealed record TxIoRow(string Position, string Address, string Amount, bool IsMine); + +/// +/// ViewModel della finestra di dettaglio transazione: prende un +/// (assemblato dal server) e ne ricava tutte +/// le stringhe già formattate e localizzate per la vista. È di sola lettura. +/// +public sealed class TransactionDetailsViewModel +{ + private readonly string _unit; + + public Loc Loc { get; } + + public TransactionDetailsViewModel(TransactionDetails d, Loc loc, string unit) + { + Loc = loc; + _unit = unit; + + Txid = d.Txid; + StatusText = BuildStatus(d, loc); + DateText = d.BlockTime is { } t + ? t.ToLocalTime().ToString("dd/MM/yyyy HH:mm") + : loc["tx.mempool"]; + + var counterparties = d.CounterpartyAddresses; + CounterpartyHeader = d.IsIncoming ? loc["tx.from"] : loc["tx.to"]; + CounterpartyText = counterparties.Count > 0 + ? string.Join(Environment.NewLine, counterparties) + : "—"; + + // Debito (uscita verso terzi) o Credito (entrata netta sui nostri output). + AmountHeader = d.IsIncoming ? loc["tx.credit"] : loc["tx.debit"]; + AmountText = d.IsIncoming + ? Signed(d.ReceivedSats) + : Signed(-d.SentToOthersSats); + + FeeText = d.FeeSats is { } fee + ? (d.IsIncoming ? Abs(fee) : Signed(-fee)) + : "—"; + + NetText = Signed(d.NetSats); + + TotalSizeText = $"{d.TotalSize} byte"; + VirtualSizeText = $"{d.VirtualSize} byte"; + FeeRateText = d.FeeRateSatPerVb is { } r + ? r.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) + " sat/vB" + : "—"; + VersionText = d.Version.ToString(); + LockTimeText = d.LockTime.ToString(); + RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"]; + VerifiedText = d.Verified ? "✓ SPV" : "—"; + + Inputs = new ObservableCollection(d.Inputs.Select((i, n) => new TxIoRow( + i.IsCoinbase ? "coinbase" : $"{Shorten(i.PrevTxid)}:{i.PrevIndex}", + i.Address ?? "—", + i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—", + i.IsMine))); + + Outputs = new ObservableCollection(d.Outputs.Select(o => new TxIoRow( + $"#{o.Index}", + o.Address ?? $"({o.ScriptType})", + CoinAmount.FormatIn(o.AmountSats, unit), + o.IsMine))); + } + + public string Txid { get; } + public string StatusText { get; } + public string DateText { get; } + public string CounterpartyHeader { get; } + public string CounterpartyText { get; } + public string AmountHeader { get; } + public string AmountText { get; } + public string FeeText { get; } + public string NetText { get; } + public string TotalSizeText { get; } + public string VirtualSizeText { get; } + public string FeeRateText { get; } + public string VersionText { get; } + public string LockTimeText { get; } + public string RbfText { get; } + public string VerifiedText { get; } + public ObservableCollection Inputs { get; } + public ObservableCollection Outputs { get; } + + private static string BuildStatus(TransactionDetails d, Loc loc) + { + if (d.Confirmations <= 0) + return loc["tx.status.mempool"]; + return $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})"; + } + + private string Signed(long sats) + { + var sign = sats > 0 ? "+" : sats < 0 ? "-" : ""; + return sign + CoinAmount.FormatIn(Math.Abs(sats), _unit); + } + + private string Abs(long sats) => CoinAmount.FormatIn(Math.Abs(sats), _unit); + + private static string Shorten(string txid) => + txid.Length > 16 ? $"{txid[..8]}…{txid[^4..]}" : txid; +} diff --git a/src/App/Views/MainWindow.axaml b/src/App/Views/MainWindow.axaml index 9d794e1..b9a1bda 100644 --- a/src/App/Views/MainWindow.axaml +++ b/src/App/Views/MainWindow.axaml @@ -192,19 +192,25 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + @@ -425,6 +431,130 @@ + + + + + + + + + + + + + + + + + +