diff --git a/src/App/ViewModels/TransactionDetailsViewModel.cs b/src/App/ViewModels/TransactionDetailsViewModel.cs index addee17..22e83f3 100644 --- a/src/App/ViewModels/TransactionDetailsViewModel.cs +++ b/src/App/ViewModels/TransactionDetailsViewModel.cs @@ -64,13 +64,17 @@ public sealed class TransactionDetailsViewModel RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"]; Inputs = new ObservableCollection(d.Inputs.Select((i, n) => new TxIoRow( i.IsCoinbase ? loc["tx.coinbase"] : $"{i.PrevTxid}:{i.PrevIndex}", - i.IsCoinbase ? loc["tx.coinbase.newcoins"] : i.Address ?? "—", + i.IsCoinbase + ? i.CoinbaseTag is { Length: > 0 } tag + ? $"{loc["tx.coinbase.newcoins"]} — {tag}" + : loc["tx.coinbase.newcoins"] + : 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})", + o.Address ?? (o.OpReturnText is { Length: > 0 } msg ? $"OP_RETURN: {msg}" : $"({o.ScriptType})"), CoinAmount.FormatIn(o.AmountSats, unit), o.IsMine))); } diff --git a/src/Core/Wallet/TransactionInspector.cs b/src/Core/Wallet/TransactionInspector.cs index 11313c6..9277a45 100644 --- a/src/Core/Wallet/TransactionInspector.cs +++ b/src/Core/Wallet/TransactionInspector.cs @@ -5,12 +5,18 @@ using PalladiumWallet.Core.Spv; namespace PalladiumWallet.Core.Wallet; /// An input of a transaction, with the spent output resolved from the server. +/// +/// Printable ASCII runs (e.g. pool tags like "/slush/") extracted from the coinbase +/// scriptSig; null for non-coinbase inputs or if no printable text was found. +/// public sealed record TxInputInfo( - string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase); + string PrevTxid, uint PrevIndex, long? AmountSats, string? Address, bool IsMine, bool IsCoinbase, + string? CoinbaseTag = null); /// An output of a transaction. +/// Decoded OP_RETURN payload (UTF-8, or hex if not valid text); null otherwise. public sealed record TxOutputInfo( - uint Index, long AmountSats, string? Address, string ScriptType, bool IsMine); + uint Index, long AmountSats, string? Address, string ScriptType, string? OpReturnText, bool IsMine); /// /// Complete transaction data assembled by querying the server: the raw transaction @@ -105,8 +111,9 @@ public static class TransactionInspector { var o = tx.Outputs[i]; var addr = AddrOf(o.ScriptPubKey); + var opReturn = addr is null ? OpReturnTextOf(o.ScriptPubKey) : null; outputs.Add(new TxOutputInfo( - (uint)i, o.Value.Satoshi, addr, ScriptType(o.ScriptPubKey), + (uint)i, o.Value.Satoshi, addr, ScriptType(o.ScriptPubKey), opReturn, addr is not null && ownedAddresses.Contains(addr))); } @@ -134,7 +141,7 @@ public static class TransactionInspector { if (tx.IsCoinBase) { - inputs.Add(new TxInputInfo("", inp.PrevOut.N, null, null, false, true)); + inputs.Add(new TxInputInfo("", inp.PrevOut.N, null, null, false, true, CoinbaseTagOf(inp.ScriptSig.ToBytes()))); continue; } @@ -189,4 +196,48 @@ public static class TransactionInspector } catch { return "—"; } } + + /// Decodes an OP_RETURN output's pushed data as UTF-8 text, falling back to hex if it isn't valid text. + private static string? OpReturnTextOf(Script script) + { + if (!script.IsUnspendable) return null; + try + { + var data = script.ToOps().Skip(1) + .Where(op => op.PushData is { Length: > 0 }) + .SelectMany(op => op.PushData) + .ToArray(); + return data.Length == 0 ? null : DecodeUtf8OrHex(data); + } + catch { return null; } + } + + private static string DecodeUtf8OrHex(byte[] data) + { + var text = System.Text.Encoding.UTF8.GetString(data); + var looksLikeText = !text.Contains('�') && text.All(c => !char.IsControl(c) || c is '\n' or '\r' or '\t'); + return looksLikeText ? text : "0x" + Convert.ToHexString(data); + } + + /// + /// Extracts printable-ASCII runs (≥4 chars) from a coinbase scriptSig, e.g. pool + /// tags like "/slush/" embedded among the binary BIP34 height and extranonce. + /// + private static string? CoinbaseTagOf(byte[] scriptSig) + { + var runs = new List(); + var run = new System.Text.StringBuilder(); + void Flush() + { + if (run.Length >= 4) runs.Add(run.ToString()); + run.Clear(); + } + foreach (var b in scriptSig) + { + if (b is >= 0x20 and <= 0x7E) run.Append((char)b); + else Flush(); + } + Flush(); + return runs.Count == 0 ? null : string.Join(" ", runs); + } } diff --git a/tests/PalladiumWallet.Tests/Wallet/TransactionInspectorTests.cs b/tests/PalladiumWallet.Tests/Wallet/TransactionInspectorTests.cs index 01ee0c7..b65abf2 100644 --- a/tests/PalladiumWallet.Tests/Wallet/TransactionInspectorTests.cs +++ b/tests/PalladiumWallet.Tests/Wallet/TransactionInspectorTests.cs @@ -195,6 +195,139 @@ public class TransactionInspectorTests Assert.Equal(0, server.CallCount("blockchain.block.header")); } + [Fact] + public async Task Un_output_OP_RETURN_con_testo_UTF8_viene_decodificato() + { + var account = Account(); + var tx = Net.CreateTransaction(); + tx.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0))); + tx.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0)); + tx.Outputs.Add(Money.Zero, TxNullDataTemplate.Instance.GenerateScriptPubKey("hello palladium"u8.ToArray())); + + var (server, client) = await StartAsync(tx); + await using var _ = server; await using var __ = client; + + var details = await TransactionInspector.FetchAsync( + client, Net, tx.GetHash().ToString(), tipHeight: 200, height: 101, + Owned(account), netSats: 1_000_000, verified: true); + + var opReturn = Assert.Single(details.Outputs, o => o.Index == 1); + Assert.Null(opReturn.Address); + Assert.Equal("hello palladium", opReturn.OpReturnText); + } + + [Fact] + public async Task Un_output_OP_RETURN_con_dati_binari_ricade_su_hex() + { + var account = Account(); + byte[] data = [0x00, 0x01, 0xFF, 0xFE, 0x02]; + var tx = Net.CreateTransaction(); + tx.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0))); + tx.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0)); + tx.Outputs.Add(Money.Zero, TxNullDataTemplate.Instance.GenerateScriptPubKey(data)); + + var (server, client) = await StartAsync(tx); + await using var _ = server; await using var __ = client; + + var details = await TransactionInspector.FetchAsync( + client, Net, tx.GetHash().ToString(), tipHeight: 200, height: 101, + Owned(account), netSats: 1_000_000, verified: true); + + var opReturn = Assert.Single(details.Outputs, o => o.Index == 1); + Assert.Equal("0x" + Convert.ToHexString(data), opReturn.OpReturnText); + } + + [Fact] + public async Task Piu_output_OP_RETURN_nella_stessa_tx_sono_decodificati_singolarmente() + { + var account = Account(); + var tx = Net.CreateTransaction(); + tx.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0))); + tx.Outputs.Add(Money.Satoshis(1_000_000), account.GetReceiveAddress(0)); + tx.Outputs.Add(Money.Zero, TxNullDataTemplate.Instance.GenerateScriptPubKey("first"u8.ToArray())); + tx.Outputs.Add(Money.Zero, TxNullDataTemplate.Instance.GenerateScriptPubKey("second"u8.ToArray())); + + var (server, client) = await StartAsync(tx); + await using var _ = server; await using var __ = client; + + var details = await TransactionInspector.FetchAsync( + client, Net, tx.GetHash().ToString(), tipHeight: 200, height: 101, + Owned(account), netSats: 1_000_000, verified: true); + + Assert.Equal("first", details.Outputs.Single(o => o.Index == 1).OpReturnText); + Assert.Equal("second", details.Outputs.Single(o => o.Index == 2).OpReturnText); + } + + [Fact] + public async Task Un_output_normale_non_ha_testo_OP_RETURN() + { + var (funding, spend, _, account) = SpendPair(); + var (server, client) = await StartAsync(funding, spend); + await using var _ = server; await using var __ = client; + + var details = await TransactionInspector.FetchAsync( + client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101, + Owned(account), netSats: -610_000, verified: true); + + Assert.All(details.Outputs, o => Assert.Null(o.OpReturnText)); + } + + [Fact] + public async Task Il_tag_pool_nello_scriptSig_della_coinbase_viene_estratto() + { + var account = Account(); + var coinbase = Net.CreateTransaction(); + // BIP34 height push (binary) followed by a pool tag (printable ASCII). + var scriptSig = new Script(Op.GetPushOp(190), Op.GetPushOp("/slush/"u8.ToArray())); + coinbase.Inputs.Add(new TxIn(new OutPoint(uint256.Zero, 0xffffffff), scriptSig)); + coinbase.Outputs.Add(Money.Satoshis(5_000_000_000), account.GetReceiveAddress(0)); + + var (server, client) = await StartAsync(coinbase); + await using var _ = server; await using var __ = client; + + var details = await TransactionInspector.FetchAsync( + client, Net, coinbase.GetHash().ToString(), tipHeight: 200, height: 190, + Owned(account), netSats: 5_000_000_000, verified: true); + + var input = Assert.Single(details.Inputs); + Assert.True(input.IsCoinbase); + Assert.Contains("/slush/", input.CoinbaseTag); + } + + [Fact] + public async Task Uno_scriptSig_coinbase_senza_testo_stampabile_non_produce_tag() + { + var account = Account(); + var coinbase = Net.CreateTransaction(); + var scriptSig = new Script(Op.GetPushOp([0x00, 0x01, 0x02])); + coinbase.Inputs.Add(new TxIn(new OutPoint(uint256.Zero, 0xffffffff), scriptSig)); + coinbase.Outputs.Add(Money.Satoshis(5_000_000_000), account.GetReceiveAddress(0)); + + var (server, client) = await StartAsync(coinbase); + await using var _ = server; await using var __ = client; + + var details = await TransactionInspector.FetchAsync( + client, Net, coinbase.GetHash().ToString(), tipHeight: 200, height: 190, + Owned(account), netSats: 5_000_000_000, verified: true); + + var input = Assert.Single(details.Inputs); + Assert.Null(input.CoinbaseTag); + } + + [Fact] + public async Task Un_input_normale_non_ha_tag_coinbase() + { + var (funding, spend, _, account) = SpendPair(); + var (server, client) = await StartAsync(funding, spend); + await using var _ = server; await using var __ = client; + + var details = await TransactionInspector.FetchAsync( + client, Net, spend.GetHash().ToString(), tipHeight: 200, height: 101, + Owned(account), netSats: -610_000, verified: true); + + Assert.All(details.Inputs, i => Assert.Null(i.CoinbaseTag)); + } + [Fact] public async Task La_cache_delle_tx_evita_le_richieste_al_server() {