feat(wallet): decode OP_RETURN payloads and coinbase pool tags in tx details
Transaction detail view silently dropped OP_RETURN messages and coinbase scriptSig text (e.g. pool tags), since TransactionInspector discarded scriptPubKey/scriptSig bytes once no destination address could be derived. Each output/input is decoded independently, so multiple OP_RETURN outputs in one tx are all shown.
This commit is contained in:
@@ -64,13 +64,17 @@ public sealed class TransactionDetailsViewModel
|
|||||||
RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"];
|
RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"];
|
||||||
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
|
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
|
||||||
i.IsCoinbase ? loc["tx.coinbase"] : $"{i.PrevTxid}:{i.PrevIndex}",
|
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.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—",
|
||||||
i.IsMine)));
|
i.IsMine)));
|
||||||
|
|
||||||
Outputs = new ObservableCollection<TxIoRow>(d.Outputs.Select(o => new TxIoRow(
|
Outputs = new ObservableCollection<TxIoRow>(d.Outputs.Select(o => new TxIoRow(
|
||||||
$"#{o.Index}",
|
$"#{o.Index}",
|
||||||
o.Address ?? $"({o.ScriptType})",
|
o.Address ?? (o.OpReturnText is { Length: > 0 } msg ? $"OP_RETURN: {msg}" : $"({o.ScriptType})"),
|
||||||
CoinAmount.FormatIn(o.AmountSats, unit),
|
CoinAmount.FormatIn(o.AmountSats, unit),
|
||||||
o.IsMine)));
|
o.IsMine)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ using PalladiumWallet.Core.Spv;
|
|||||||
namespace PalladiumWallet.Core.Wallet;
|
namespace PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
/// <summary>An input of a transaction, with the spent output resolved from the server.</summary>
|
/// <summary>An input of a transaction, with the spent output resolved from the server.</summary>
|
||||||
|
/// <param name="CoinbaseTag">
|
||||||
|
/// 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.
|
||||||
|
/// </param>
|
||||||
public sealed record TxInputInfo(
|
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);
|
||||||
|
|
||||||
/// <summary>An output of a transaction.</summary>
|
/// <summary>An output of a transaction.</summary>
|
||||||
|
/// <param name="OpReturnText">Decoded OP_RETURN payload (UTF-8, or hex if not valid text); null otherwise.</param>
|
||||||
public sealed record TxOutputInfo(
|
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);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Complete transaction data assembled by querying the server: the raw transaction
|
/// 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 o = tx.Outputs[i];
|
||||||
var addr = AddrOf(o.ScriptPubKey);
|
var addr = AddrOf(o.ScriptPubKey);
|
||||||
|
var opReturn = addr is null ? OpReturnTextOf(o.ScriptPubKey) : null;
|
||||||
outputs.Add(new TxOutputInfo(
|
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)));
|
addr is not null && ownedAddresses.Contains(addr)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +141,7 @@ public static class TransactionInspector
|
|||||||
{
|
{
|
||||||
if (tx.IsCoinBase)
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,4 +196,48 @@ public static class TransactionInspector
|
|||||||
}
|
}
|
||||||
catch { return "—"; }
|
catch { return "—"; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Decodes an OP_RETURN output's pushed data as UTF-8 text, falling back to hex if it isn't valid text.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts printable-ASCII runs (≥4 chars) from a coinbase scriptSig, e.g. pool
|
||||||
|
/// tags like "/slush/" embedded among the binary BIP34 height and extranonce.
|
||||||
|
/// </summary>
|
||||||
|
private static string? CoinbaseTagOf(byte[] scriptSig)
|
||||||
|
{
|
||||||
|
var runs = new List<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,139 @@ public class TransactionInspectorTests
|
|||||||
Assert.Equal(0, server.CallCount("blockchain.block.header"));
|
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]
|
[Fact]
|
||||||
public async Task La_cache_delle_tx_evita_le_richieste_al_server()
|
public async Task La_cache_delle_tx_evita_le_richieste_al_server()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user