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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App/Views/MainWindow.axaml.cs b/src/App/Views/MainWindow.axaml.cs
index 9c0e857..8c8153b 100644
--- a/src/App/Views/MainWindow.axaml.cs
+++ b/src/App/Views/MainWindow.axaml.cs
@@ -37,6 +37,23 @@ public partial class MainWindow : Window
vm.OpenFromPath(path);
}
+ private void OnHistoryRowDoubleTapped(object? sender, TappedEventArgs e)
+ {
+ if (sender is not ListBox lb || DataContext is not MainWindowViewModel vm) return;
+ if (lb.SelectedItem is not HistoryRow row) return;
+
+ // Overlay in-app: appare subito con lo spinner, i dati arrivano dal
+ // server in background. Niente top-level window (lenta da aprire/chiudere).
+ _ = vm.ShowTransactionDetailsAsync(row.Txid);
+ }
+
+ private void OnTxDetailsOverlayBackdropTapped(object? sender, TappedEventArgs e)
+ {
+ if (!ReferenceEquals(e.Source, sender)) return;
+ if (DataContext is MainWindowViewModel vm)
+ vm.CloseTransactionDetailsCommand.Execute(null);
+ }
+
private void OnAddressListTapped(object? sender, TappedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm || vm.SelectedAddressRow is not { } row)
@@ -115,6 +132,7 @@ public partial class MainWindow : Window
{
if (e.Key == Key.Escape && DataContext is MainWindowViewModel vm)
{
+ if (vm.IsTxDetailsOpen) { vm.CloseTransactionDetailsCommand.Execute(null); e.Handled = true; return; }
if (vm.AddressInfo is not null) { vm.AddressInfo = null; e.Handled = true; return; }
if (vm.IsServerSettingsOpen) { vm.IsServerSettingsOpen = false; e.Handled = true; return; }
if (vm.IsSettingsOpen) { vm.IsSettingsOpen = false; e.Handled = true; return; }
diff --git a/src/Core/Wallet/TransactionInspector.cs b/src/Core/Wallet/TransactionInspector.cs
new file mode 100644
index 0000000..1eea277
--- /dev/null
+++ b/src/Core/Wallet/TransactionInspector.cs
@@ -0,0 +1,192 @@
+using NBitcoin;
+using PalladiumWallet.Core.Net;
+using PalladiumWallet.Core.Spv;
+
+namespace PalladiumWallet.Core.Wallet;
+
+/// Un input di una transazione, con l'output speso risolto dal server.
+public sealed record TxInputInfo(
+ string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase);
+
+/// Un output di una transazione.
+public sealed record TxOutputInfo(
+ uint Index, long AmountSats, string? Address, string ScriptType, bool IsMine);
+
+///
+/// Dati completi di una transazione, assemblati interrogando il server: la tx
+/// grezza più gli output spesi dagli input (per ricavare importi, indirizzi e
+/// fee) e l'header del blocco (per la data). Tutto ciò che il protocollo
+/// ElectrumX-like (§10) permette di sapere su una transazione.
+///
+public sealed class TransactionDetails
+{
+ public required string Txid { get; init; }
+ /// Altezza del blocco; ≤0 = ancora in mempool.
+ public required int Height { get; init; }
+ public required int Confirmations { get; init; }
+ /// Effetto netto sul saldo del wallet (delta calcolato in sincronizzazione).
+ public required long NetSats { get; init; }
+ /// Fee della transazione; null se un input ha importo non risolvibile (es. coinbase).
+ public required long? FeeSats { get; init; }
+ public required int TotalSize { get; init; }
+ public required int VirtualSize { get; init; }
+ public required uint Version { get; init; }
+ public required uint LockTime { get; init; }
+ public required bool RbfSignaled { get; init; }
+ /// Merkle proof verificata in sincronizzazione (§7.4).
+ public required bool Verified { get; init; }
+ public required DateTimeOffset? BlockTime { get; init; }
+ public required long TotalOutSats { get; init; }
+ public required long? TotalInSats { get; init; }
+ public required IReadOnlyList Inputs { get; init; }
+ public required IReadOnlyList Outputs { get; init; }
+
+ public bool IsCoinbase => Inputs.Count > 0 && Inputs[0].IsCoinbase;
+ public bool IsIncoming => NetSats >= 0;
+ /// Importo verso destinatari esterni (output non nostri): l'importo "inviato".
+ public long SentToOthersSats => Outputs.Where(o => !o.IsMine).Sum(o => o.AmountSats);
+ public long ReceivedSats => Outputs.Where(o => o.IsMine).Sum(o => o.AmountSats);
+ public double? FeeRateSatPerVb => FeeSats is { } f && VirtualSize > 0 ? (double)f / VirtualSize : null;
+ ///
+ /// Indirizzi della controparte: i destinatari esterni per un invio (output non
+ /// nostri), i mittenti esterni per una ricezione (input non nostri).
+ ///
+ public IReadOnlyList CounterpartyAddresses => IsIncoming
+ ? [.. Inputs.Where(i => !i.IsMine && i.Address is not null).Select(i => i.Address!).Distinct()]
+ : [.. Outputs.Where(o => !o.IsMine && o.Address is not null).Select(o => o.Address!).Distinct()];
+}
+
+///
+/// Recupera dal server tutti i dati di una singola transazione (blueprint §10):
+/// la transazione grezza e gli output spesi dai suoi input, per ricostruire
+/// importi, fee e indirizzi che il server non riassume.
+///
+public static class TransactionInspector
+{
+ public static async Task FetchAsync(
+ ElectrumClient client, Network network, string txid, int tipHeight, int height,
+ IReadOnlySet ownedAddresses, long netSats, bool verified,
+ IReadOnlyDictionary? cache = null,
+ CancellationToken ct = default)
+ {
+ async Task GetTx(string id)
+ {
+ if (cache is not null && cache.TryGetValue(id, out var hit))
+ return hit;
+ return Transaction.Parse(await client.GetTransactionAsync(id, ct), network);
+ }
+
+ async Task GetTxOrNull(string id)
+ {
+ try { return await GetTx(id); }
+ catch { return null; }
+ }
+
+ async Task GetBlockTimeOrNull()
+ {
+ try
+ {
+ var header = BlockHeaderInfo.Parse(await client.GetBlockHeaderAsync(height, ct));
+ return DateTimeOffset.FromUnixTimeSeconds(header.Timestamp);
+ }
+ catch { return null; }
+ }
+
+ string? AddrOf(Script s)
+ {
+ try { return s.GetDestinationAddress(network)?.ToString(); }
+ catch { return null; }
+ }
+
+ var tx = await GetTx(txid);
+
+ var outputs = new List(tx.Outputs.Count);
+ for (var i = 0; i < tx.Outputs.Count; i++)
+ {
+ var o = tx.Outputs[i];
+ var addr = AddrOf(o.ScriptPubKey);
+ outputs.Add(new TxOutputInfo(
+ (uint)i, o.Value.Satoshi, addr, ScriptType(o.ScriptPubKey),
+ addr is not null && ownedAddresses.Contains(addr)));
+ }
+
+ var rbf = tx.Inputs.Any(i => i.Sequence.IsRBF);
+
+ // Le transazioni degli input servono per importi/indirizzi/fee. Si
+ // scaricano in parallelo (id univoci, richieste concorrenti supportate
+ // da ElectrumClient): in sequenza la finestra impiegava un round-trip
+ // per input. Anche l'header del blocco è recuperato in parallelo.
+ var prevTxids = tx.IsCoinBase
+ ? []
+ : tx.Inputs.Select(i => i.PrevOut.Hash.ToString()).Distinct().ToList();
+
+ var prevFetch = prevTxids.ToDictionary(id => id, id => GetTxOrNull(id));
+ var headerTask = height > 0 ? GetBlockTimeOrNull() : Task.FromResult(null);
+ await Task.WhenAll(prevFetch.Values.Cast().Append(headerTask));
+
+ var prevTxs = prevFetch.ToDictionary(kv => kv.Key, kv => kv.Value.Result);
+ var blockTime = await headerTask;
+
+ var inputs = new List(tx.Inputs.Count);
+ var feeKnown = !tx.IsCoinBase;
+ long inSum = 0;
+ foreach (var inp in tx.Inputs)
+ {
+ if (tx.IsCoinBase)
+ {
+ inputs.Add(new TxInputInfo("", inp.PrevOut.N, null, null, false, true));
+ continue;
+ }
+
+ long? amt = null;
+ string? addr = null;
+ var prev = prevTxs.GetValueOrDefault(inp.PrevOut.Hash.ToString());
+ if (prev is not null && inp.PrevOut.N < prev.Outputs.Count)
+ {
+ var po = prev.Outputs[(int)inp.PrevOut.N];
+ amt = po.Value.Satoshi;
+ addr = AddrOf(po.ScriptPubKey);
+ inSum += po.Value.Satoshi;
+ }
+ else feeKnown = false;
+
+ inputs.Add(new TxInputInfo(
+ inp.PrevOut.Hash.ToString(), inp.PrevOut.N, amt, addr,
+ addr is not null && ownedAddresses.Contains(addr), false));
+ }
+
+ var outSum = tx.Outputs.Sum(o => o.Value.Satoshi);
+
+ return new TransactionDetails
+ {
+ Txid = txid,
+ Height = height,
+ Confirmations = height > 0 && tipHeight >= height ? tipHeight - height + 1 : 0,
+ NetSats = netSats,
+ FeeSats = feeKnown ? inSum - outSum : null,
+ TotalSize = tx.ToBytes().Length,
+ VirtualSize = tx.GetVirtualSize(),
+ Version = tx.Version,
+ LockTime = tx.LockTime.Value,
+ RbfSignaled = rbf,
+ Verified = verified,
+ BlockTime = blockTime,
+ TotalOutSats = outSum,
+ TotalInSats = feeKnown ? inSum : null,
+ Inputs = inputs,
+ Outputs = outputs,
+ };
+ }
+
+ private static string ScriptType(Script script)
+ {
+ try
+ {
+ var t = StandardScripts.GetTemplateFromScriptPubKey(script);
+ return t is null
+ ? "nonstandard"
+ : t.GetType().Name.Replace("PayTo", "").Replace("Template", "");
+ }
+ catch { return "—"; }
+ }
+}