5 Commits

Author SHA1 Message Date
davide 1a4fefadc3 fix(wallet): keep transactions under the standard 100 KvB relay limit
Sending a large amount from a wallet with many small UTXOs could
produce a transaction over the standard relay size limit, surfaced
only as a cryptic "Invalid transaction: Transaction's size is too
high" from builder.Verify() after everything else had already
succeeded — with no way for the user to recover other than manually
picking fewer coins.

Build() now orders spendable UTXOs largest-first and binary-searches
the smallest prefix of them that produces a valid transaction, so it
naturally prefers big coins over dust and avoids the limit whenever a
smaller input set can cover the amount. NBitcoin's own coin selector
already refuses to assemble a combination over the size cap and
reports it via NotEnoughFundsException with a distinctive message
rather than ever handing back an oversized transaction, so that case
is now recognized and translated into a clear, actionable error
instead of being read as "insufficient funds".
2026-07-17 19:44:41 +02:00
davide 3460e53b4f feat(spv): verify Merkle proofs progressively, gate spendability on it
Balance/history now render as soon as tx downloads finish instead of
blocking on every historical Merkle proof, critical for mobile where
proof-checking can take much longer than the download itself. Proofs
continue to be checked in the background and each tx's Verified flag
catches up progressively; header ranges are now fetched in batches
(blockchain.block.headers) instead of one call per header to keep this
fast over high-latency links.

Coin selection (UtxoSpendability.IsSpendable) refuses to spend a UTXO
until its Merkle proof is actually checked, regardless of confirmation
count, so a server that fabricates a confirmed balance can get it
displayed early but never spent before the forgery is caught. The disk
cache only ever persists the fully-verified end state of a sync.

UI surfaces the new PendingVerificationSats/SpendableSats split with a
"verifying..." badge, and the sync save now runs off the UI thread to
avoid freezing on slower hardware.
2026-07-17 19:43:18 +02:00
davide feb765663c chore(chain): add mainnet checkpoint at height 475124 2026-07-17 15:14:24 +02:00
davide 322ce8f305 chore(release): bump version to 1.0.0
Fills in the CHANGELOG.md entry for the checkpoint-anchoring security fix,
fuzzing infrastructure and its findings, OP_RETURN/coinbase-tag decoding,
and localization fixes since 0.9.1 (see previous commits), and bumps
<Version>/versionCode across the App and Android head csproj files ahead
of the tag.
2026-07-17 14:02:52 +02:00
davide 077835a30b 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.
2026-07-09 08:35:39 +02:00
23 changed files with 963 additions and 100 deletions
+102
View File
@@ -5,6 +5,108 @@ Technical changelog for PalladiumWallet. Format loosely follows
by subsystem rather than strictly by date, since `0.9.0` is the first by subsystem rather than strictly by date, since `0.9.0` is the first
release and covers the full history from the initial commit. release and covers the full history from the initial commit.
## [1.0.0] — 2026-07-09
First stable release. Closes the last open security gap from 0.9.x (header
trust was not actually anchored to any checkpoint), fixes several crash
paths found by a new fuzzing harness, and adds OP_RETURN/coinbase-tag
decoding to the transaction detail view.
### Security
- `WalletSynchronizer.AnchorToCheckpointAsync`: header trust is now actually
anchored to `ChainProfiles.Mainnet.Checkpoints` (24 real `[height, hash,
bits]` checkpoints pulled from a fully-synced node, every 20,000 blocks
plus one near tip). Previously the checkpoint array was empty and the
methods meant to enforce it (`MatchesCheckpoint`/`IsValidChild`) were
never called — on this LWMA chain, where PoW can't be recomputed
client-side, a malicious or eclipsing server could hand back any
internally-consistent header for a Merkle proof with nothing tying it to
the real chain. For every header used in a Merkle proof, the intervening
headers are now downloaded back to the nearest checkpoint and verified as
an unbroken prev-hash chain terminating in that checkpoint's exact hash
(memoized per sync session). Testnet/Regtest remain unanchored (no node
available to source checkpoints from); a missing checkpoint is a no-op,
not a failure.
- New fuzzing harness (`tests/PalladiumWallet.Fuzz`, SharpFuzz-based): one
target per untrusted-input parser (header, Merkle proof, peer list,
wallet file, mnemonic/key/address/amount), each encoding that parser's
documented error contract. Found and fixed:
- `Bip39.TryParse` threw `NotSupportedException` on text resembling no
wordlist instead of failing gracefully.
- `ElectrumApi.ParsePeers` threw on any `server.peers.subscribe` response
shape other than the expected `[ip, hostname, [features...]]`, and on a
JSON string containing invalid UTF-8.
- `EncryptedFile.Decrypt` let a tampered/corrupted wallet file escape as
raw `JsonException`/`FormatException`/`ArgumentNullException` instead of
the documented `WrongPasswordException`/`InvalidDataException`
contract; worse, the PBKDF2 iteration count was read from the
(attacker-controlled) container with no upper bound — a tampered file
demanding e.g. 2³¹ iterations would hang the wallet on open. Iteration
count is now clamped to 10,000,000.
- `CertificatePinStore.Load`: a corrupted pin file blocked every SSL
connection until manually deleted; now falls back to first-contact
TOFU like `ServerRegistry` already did.
- The seed corpus (incl. a regression input per fixed finding) replays
inside `dotnet test` via `FuzzCorpusTests`, so a fix can't silently
regress.
- `SECURITY.md` corrected: PBKDF2 parameters (600,000 iterations / 16-byte
salt, not the pre-hardening 100,000 / 32-byte), a stale file reference,
and disclosure of the AI-assisted testing methodology used (adversarial
fake-server simulation, property-based fuzzing, targeted security
review) as a complement to, not a replacement for, independent review.
### Added
- Transaction detail view now decodes OP_RETURN output payloads (UTF-8, or
hex if the bytes aren't valid text — multiple OP_RETURN outputs in one tx
are each decoded independently) and coinbase scriptSig pool tags (e.g.
`/slush/`, extracted as printable-ASCII runs amid the binary BIP34
height/extranonce). Both were previously dropped entirely — discarded
once no destination address could be derived from the script.
- Help overlay: "User guide" button next to "Report a bug", linking to
`USERGUIDE.md` on GitHub.
- `USERGUIDE.md`: full end-user reference for GUI and CLI (wizard flows,
script types, fees, gap limit, TOFU cert pinning, CLI commands) with the
exact numbers the software enforces.
### Fixed
- Send/Donate/Sync/Wizard view models wrote status/error strings directly
in Italian regardless of the active language; routed through `Loc` with
the missing keys added. `CertificatePinMismatchException` no longer
bakes an Italian message into `.Message` — it exposes `Host`/`Port` for
the UI to translate.
- CLI (`src/Cli/Program.cs`) printed all output in Italian regardless of
the code/docs-are-English-only policy; translated every user-facing
string and comment.
### Testing
- Test suite expanded from 307 to 392 tests, closing coverage gaps in:
checkpoint-anchoring (including the memoization and non-generic-retry
branches), the PoW-checked branch of `BlockHeaderInfo.IsValidChild`
(never run since every profile sets `SkipPowValidation`), all 8 BIP-39
wordlist languages plus the empty-input guard, SLIP-132 rejection of
malformed/corrupted keys, `TransactionFactory`'s standardness-policy
rejection, `ImportedKeyAccount`'s fund-safety fallbacks,
`WalletLoader`'s defensive branches for corrupted files,
`PalladiumNetworks.For`/`INetworkSet`, `AppPaths`' full data-root
precedence chain (via new internal override seams), `UpdateChecker`
end-to-end via a stub-transport seam, and `ElectrumClient`'s
multi-segment response dispatch.
- OP_RETURN/coinbase-tag decoding covered: UTF-8 text, binary fallback to
hex, multiple OP_RETURN outputs in one tx, pool-tag extraction, and the
absence of false positives on standard outputs/inputs.
### Documentation
- `AGENTS.md`/`CLAUDE.md` re-synced (had drifted) and reformatted from
dense prose to scannable bullet lists; a stale `SECURITY.md` file
reference fixed.
- `README.md` test-coverage section and `SECURITY.md` updated to describe
the checkpoint anchoring and fuzzing guarantees actually enforced now.
## [0.9.1] — 2026-07-02 ## [0.9.1] — 2026-07-02
### Testing ### Testing
+14
View File
@@ -41,6 +41,20 @@ It cannot (given correct Merkle verification):
- Fabricate a confirmed transaction with a valid Merkle proof - Fabricate a confirmed transaction with a valid Merkle proof
- Forge a payment to a wrong address - Forge a payment to a wrong address
**Progressive verification (mobile-friendly sync).** On a wallet with many historical
transactions, `WalletSynchronizer` no longer blocks the whole sync on every Merkle proof:
balance and history are shown as soon as transaction downloads finish (`PartialResult`,
`Core/Spv/WalletSynchronizer.cs`), while proofs continue to be checked in the background and
each transaction's `Verified` flag catches up progressively. This means the UI can display a
server-reported balance/history that includes not-yet-verified entries — clearly marked with a
"verifying…" badge and a separate non-spendable total (`PendingVerificationSats`). The
security-critical invariant this depends on: coin selection (`TransactionFactory`, gated by
`Wallet/UtxoSpendability.IsSpendable`) refuses to spend a UTXO whose `Verified` flag isn't true,
regardless of confirmations — so a server that fabricates a fake confirmed balance can get it
*displayed* early, but never *spent*, before the forged Merkle proof is caught and the sync
fails outright. The disk cache (`SyncCache`) only ever persists the fully-verified end state of
a sync, never a partial one, so no unverified data survives a restart.
--- ---
## Key and seed management ## Key and seed management
+1
View File
@@ -205,6 +205,7 @@ build_android() {
-t:SignAndroidPackage \ -t:SignAndroidPackage \
-p:AndroidSdkDirectory=\${ANDROID_HOME} \ -p:AndroidSdkDirectory=\${ANDROID_HOME} \
-p:ApplicationVersion=${VERSION_CODE} \ -p:ApplicationVersion=${VERSION_CODE} \
-p:AndroidKeyStore=true \
-p:AndroidSigningKeyStore=/keystore/release.keystore \ -p:AndroidSigningKeyStore=/keystore/release.keystore \
-p:AndroidSigningKeyAlias=palladiumwallet \ -p:AndroidSigningKeyAlias=palladiumwallet \
-p:AndroidSigningStorePass=\${ANDROID_KS_PASS} \ -p:AndroidSigningStorePass=\${ANDROID_KS_PASS} \
@@ -8,8 +8,8 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId> <ApplicationId>io.github.davide3011.palladiumwallet</ApplicationId>
<!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName --> <!-- ApplicationVersion = versionCode (intero), ApplicationDisplayVersion = versionName -->
<ApplicationVersion>2</ApplicationVersion> <ApplicationVersion>3</ApplicationVersion>
<ApplicationDisplayVersion>0.9.1</ApplicationDisplayVersion> <ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
<AndroidPackageFormat>apk</AndroidPackageFormat> <AndroidPackageFormat>apk</AndroidPackageFormat>
<!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast <!-- Includi le assembly .NET DENTRO l'apk: senza, in Debug si usa il Fast
Deployment (assembly spinte via adb da `dotnet run`) e un apk installato Deployment (assembly spinte via adb da `dotnet run`) e un apk installato
+2
View File
@@ -395,6 +395,8 @@ public sealed class Loc
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"], ["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"],
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"], ["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"],
["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung"], ["msg.immature"] = ["in maturazione", "maturing", "en maduración", "en maturation", "em maturação", "in Reifung"],
["msg.verifying"] = ["in verifica SPV", "SPV-verifying", "en verificación SPV", "en cours de vérification SPV", "em verificação SPV", "SPV-Prüfung läuft"],
["history.unverified"] = ["in verifica…", "verifying…", "verificando…", "vérification…", "verificando…", "wird geprüft…"],
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."], ["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
["msg.certreset"] = [ ["msg.certreset"] = [
"Certificati SSL azzerati: riprova la connessione.", "Certificati SSL azzerati: riprova la connessione.",
+1 -1
View File
@@ -6,7 +6,7 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<!-- Versione dell'applicazione: unico punto da modificare. Compare nel <!-- Versione dell'applicazione: unico punto da modificare. Compare nel
titolo della finestra ed è incisa nei binari pubblicati. --> titolo della finestra ed è incisa nei binari pubblicati. -->
<Version>0.9.1</Version> <Version>1.0.0</Version>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup> </PropertyGroup>
@@ -28,6 +28,9 @@ public partial class MainWindowViewModel
[ObservableProperty] [ObservableProperty]
private string immatureText = ""; private string immatureText = "";
[ObservableProperty]
private string verifyingText = "";
[ObservableProperty] [ObservableProperty]
private string networkInfo = ""; private string networkInfo = "";
@@ -253,6 +256,7 @@ public partial class MainWindowViewModel
BalanceText = $"0.00000000 {Profile.CoinUnit}"; BalanceText = $"0.00000000 {Profile.CoinUnit}";
UnconfirmedText = ""; UnconfirmedText = "";
ImmatureText = ""; ImmatureText = "";
VerifyingText = "";
ReceiveAddress = _account.GetReceiveAddress(0).ToString(); ReceiveAddress = _account.GetReceiveAddress(0).ToString();
History.Clear(); History.Clear();
Addresses.Clear(); Addresses.Clear();
@@ -265,7 +269,11 @@ public partial class MainWindowViewModel
$"m/{_doc!.AccountPath}/0/{i}")); $"m/{_doc!.AccountPath}/0/{i}"));
return; return;
} }
BalanceText = Fmt(cache.ConfirmedSats - cache.ImmatureSats); // Not "Confirmed - Immature - PendingVerification": those two can overlap (an
// immature coinbase can also be unverified), which double-subtracts and can go
// negative. SpendableSats is computed directly from the same IsSpendable gate
// coin selection uses, so it's always correct.
BalanceText = Fmt(cache.SpendableSats);
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats); var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
UnconfirmedText = pending != 0 UnconfirmedText = pending != 0
? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}" ? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
@@ -273,13 +281,20 @@ public partial class MainWindowViewModel
ImmatureText = cache.ImmatureSats != 0 ImmatureText = cache.ImmatureSats != 0
? $"{Loc.Tr("msg.immature")}: {Fmt(cache.ImmatureSats)} — {Loc.Tr("msg.notspendable")}" ? $"{Loc.Tr("msg.immature")}: {Fmt(cache.ImmatureSats)} — {Loc.Tr("msg.notspendable")}"
: ""; : "";
// Progressive verification (§7.4): funds confirmed by the server but whose Merkle
// proof background verification hasn't reached yet — same "not spendable" treatment
// as immature/pending, distinct wording so it doesn't read as a maturity/confirmation problem.
VerifyingText = cache.PendingVerificationSats != 0
? $"{Loc.Tr("msg.verifying")}: {Fmt(cache.PendingVerificationSats)} — {Loc.Tr("msg.notspendable")}"
: "";
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString(); ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
History.Clear(); History.Clear();
foreach (var tx in cache.History) foreach (var tx in cache.History)
History.Add(new HistoryRow( History.Add(new HistoryRow(
tx.Height > 0 ? tx.Height.ToString() : "mempool", tx.Height > 0 ? tx.Height.ToString() : "mempool",
(tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false), (tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
tx.Txid)); tx.Txid,
tx.Verified));
Addresses.Clear(); Addresses.Clear();
foreach (var a in cache.Addresses) foreach (var a in cache.Addresses)
+59 -19
View File
@@ -273,32 +273,34 @@ public partial class MainWindowViewModel
_doc.Cache?.NextChangeIndex ?? 0, _doc.Cache?.NextChangeIndex ?? 0,
net); net);
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg); _synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
// Progressive verification (§7.4): the wallet becomes usable as soon as
// transaction downloads finish, without waiting for every historical Merkle
// proof — critical on mobile, where verifying thousands of proofs can take far
// longer than the download itself. Never persisted to disk on its own (only the
// final, fully-verified snapshot below is saved); coin selection still refuses
// any UTXO whose proof isn't checked yet (CachedUtxo.Verified), so showing this
// early can't be exploited to spend a server-fabricated balance.
_synchronizer.PartialResult += r => Dispatcher.UIThread.Post(() => ApplyPartialResult(r));
} }
do do
{ {
_resyncRequested = false; _resyncRequested = false;
var result = await _synchronizer.SyncOnceAsync(ct); // Off the UI thread: sync does heavy CPU work (LINQ over thousands of
// cached txs/UTXOs) and blocking JSON/disk I/O between awaits, negligible
// on desktop but enough to freeze the UI (ANR) on slower mobile hardware.
var (result, rawHex, verifiedAt, blockHeaders) = await Task.Run(async () =>
{
var r = await _synchronizer.SyncOnceAsync(ct);
var caches = _synchronizer.ExportCaches(PalladiumNetworks.For(_account.Profile.Kind));
return (r, caches.RawTxHex, caches.VerifiedAt, caches.BlockHeaders);
}, ct);
_lastTransactions = result.Transactions; _lastTransactions = result.Transactions;
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches( var cache = new SyncCache { RawTxHex = rawHex, VerifiedAt = verifiedAt, BlockHeaders = blockHeaders };
PalladiumNetworks.For(_account.Profile.Kind)); FillDisplayFields(cache, result);
_doc.Cache = new SyncCache _doc.Cache = cache;
{ await WalletStore.SaveAsync(_doc, _walletPath!, _password);
TipHeight = result.TipHeight,
ConfirmedSats = result.ConfirmedSats,
UnconfirmedSats = result.UnconfirmedSats,
ImmatureSats = result.ImmatureSats,
NextReceiveIndex = result.NextReceiveIndex,
NextChangeIndex = result.NextChangeIndex,
History = [.. result.History],
Utxos = [.. result.Utxos],
Addresses = [.. result.AddressRows],
RawTxHex = rawHex,
VerifiedAt = verifiedAt,
BlockHeaders = blockHeaders,
};
WalletStore.Save(_doc, _walletPath!, _password);
ApplyCache(_doc.Cache); ApplyCache(_doc.Cache);
_syncFailed = false; _syncFailed = false;
StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " + StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
@@ -345,6 +347,44 @@ public partial class MainWindowViewModel
_ = ConnectAndSync(); _ = ConnectAndSync();
} }
/// <summary>
/// Applies a provisional (not-yet-fully-verified) snapshot fired mid-sync: updates the
/// in-memory display cache only — never persisted to disk on its own, so an interrupted
/// sync can't leave behind a cache file whose VerifiedAt/RawTxHex/BlockHeaders (needed to
/// resume without re-downloading) were never written.
/// </summary>
private void ApplyPartialResult(SyncResult result)
{
if (_doc is null)
return;
var previous = _doc.Cache;
var cache = new SyncCache
{
RawTxHex = previous?.RawTxHex,
VerifiedAt = previous?.VerifiedAt,
BlockHeaders = previous?.BlockHeaders,
};
FillDisplayFields(cache, result);
_doc.Cache = cache;
_lastTransactions = result.Transactions;
ApplyCache(cache);
}
private static void FillDisplayFields(SyncCache cache, SyncResult result)
{
cache.TipHeight = result.TipHeight;
cache.ConfirmedSats = result.ConfirmedSats;
cache.UnconfirmedSats = result.UnconfirmedSats;
cache.ImmatureSats = result.ImmatureSats;
cache.PendingVerificationSats = result.PendingVerificationSats;
cache.SpendableSats = result.SpendableSats;
cache.NextReceiveIndex = result.NextReceiveIndex;
cache.NextChangeIndex = result.NextChangeIndex;
cache.History = [.. result.History];
cache.Utxos = [.. result.Utxos];
cache.Addresses = [.. result.AddressRows];
}
/// <summary> /// <summary>
/// Peer discovery. Always clickable: if the wallet is already connected, it reuses /// Peer discovery. Always clickable: if the wallet is already connected, it reuses
/// that connection; otherwise it opens a short-lived connection to a candidate server /// that connection; otherwise it opens a short-lived connection to a candidate server
+1 -1
View File
@@ -17,7 +17,7 @@ using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.App.ViewModels; namespace PalladiumWallet.App.ViewModels;
/// <summary>Transaction history row for the view.</summary> /// <summary>Transaction history row for the view.</summary>
public sealed record HistoryRow(string Conferma, string Importo, string Txid); public sealed record HistoryRow(string Conferma, string Importo, string Txid, bool Verified = true);
/// <summary>Address view row with pre-computed keys and derivation path.</summary> /// <summary>Address view row with pre-computed keys and derivation path.</summary>
public sealed record AddressRow( public sealed record AddressRow(
@@ -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)));
} }
@@ -98,7 +102,8 @@ public sealed class TransactionDetailsViewModel
{ {
if (d.Confirmations <= 0) if (d.Confirmations <= 0)
return loc["tx.status.mempool"]; return loc["tx.status.mempool"];
return $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})"; var status = $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
return d.Verified ? status : $"{status} — {loc["history.unverified"]}";
} }
private string Signed(long sats) private string Signed(long sats)
+10 -1
View File
@@ -317,6 +317,9 @@
<TextBlock Text="{Binding ImmatureText}" Foreground="#FCD34D" <TextBlock Text="{Binding ImmatureText}" Foreground="#FCD34D"
TextWrapping="Wrap" TextWrapping="Wrap"
IsVisible="{Binding ImmatureText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding ImmatureText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding VerifyingText}" Foreground="#FCD34D"
TextWrapping="Wrap"
IsVisible="{Binding VerifyingText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding NetworkInfo}" Classes="on-hero" FontSize="12" <TextBlock Text="{Binding NetworkInfo}" Classes="on-hero" FontSize="12"
Margin="0,2,0,0"/> Margin="0,2,0,0"/>
</StackPanel> </StackPanel>
@@ -373,13 +376,16 @@
<DataTemplate x:DataType="vm:HistoryRow"> <DataTemplate x:DataType="vm:HistoryRow">
<Panel Cursor="Hand"> <Panel Cursor="Hand">
<!-- Desktop: 3 fixed columns --> <!-- Desktop: 3 fixed columns -->
<Grid ColumnDefinitions="90,160,*" <Grid ColumnDefinitions="90,160,*,Auto"
IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsDesktop}"> IsVisible="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).IsDesktop}">
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}"/> <TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}"/>
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/> <TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
<TextBlock Grid.Column="2" Text="{Binding Txid}" <TextBlock Grid.Column="2" Text="{Binding Txid}"
FontFamily="monospace" FontSize="12" FontFamily="monospace" FontSize="12"
TextTrimming="CharacterEllipsis"/> TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="3" Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unverified]}"
Foreground="#FCD34D" FontSize="11" Margin="6,0,0,0"
IsVisible="{Binding !Verified}"/>
</Grid> </Grid>
<!-- Mobile: vertical card --> <!-- Mobile: vertical card -->
<StackPanel Spacing="2" <StackPanel Spacing="2"
@@ -389,6 +395,9 @@
<TextBlock Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}" FontSize="11"/> <TextBlock Text="{Binding Conferma}" Foreground="{DynamicResource TextSecondaryBrush}" FontSize="11"/>
<TextBlock Text="{Binding Txid}" FontFamily="monospace" FontSize="11" <TextBlock Text="{Binding Txid}" FontFamily="monospace" FontSize="11"
TextTrimming="CharacterEllipsis"/> TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding $parent[UserControl].((vm:MainWindowViewModel)DataContext).Loc[history.unverified]}"
Foreground="#FCD34D" FontSize="11"
IsVisible="{Binding !Verified}"/>
</StackPanel> </StackPanel>
</Panel> </Panel>
</DataTemplate> </DataTemplate>
+6 -2
View File
@@ -104,8 +104,9 @@ static int Info(string[] o)
Console.WriteLine($"xpub: {doc.AccountXpub}"); Console.WriteLine($"xpub: {doc.AccountXpub}");
if (doc.Cache is { } cache) if (doc.Cache is { } cache)
{ {
Console.WriteLine($"balance: {CoinAmount.Format(cache.ConfirmedSats - cache.ImmatureSats, account.Profile.CoinUnit)} spendable" Console.WriteLine($"balance: {CoinAmount.Format(cache.SpendableSats, account.Profile.CoinUnit)} spendable"
+ (cache.ImmatureSats != 0 ? $" + {CoinAmount.Format(cache.ImmatureSats)} maturing (not spendable)" : "") + (cache.ImmatureSats != 0 ? $" + {CoinAmount.Format(cache.ImmatureSats)} maturing (not spendable)" : "")
+ (cache.PendingVerificationSats != 0 ? $" + {CoinAmount.Format(cache.PendingVerificationSats)} awaiting SPV verification (not spendable)" : "")
+ (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} pending confirmation (not spendable)." : "")); + (cache.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(cache.UnconfirmedSats)} pending confirmation (not spendable)." : ""));
Console.WriteLine($"sync: height {cache.TipHeight}, {cache.History.Count} transactions"); Console.WriteLine($"sync: height {cache.TipHeight}, {cache.History.Count} transactions");
Console.WriteLine($"receive: {account.GetReceiveAddress(cache.NextReceiveIndex)}"); Console.WriteLine($"receive: {account.GetReceiveAddress(cache.NextReceiveIndex)}");
@@ -149,6 +150,8 @@ static async Task<int> Sync(string[] o)
ConfirmedSats = result.ConfirmedSats, ConfirmedSats = result.ConfirmedSats,
UnconfirmedSats = result.UnconfirmedSats, UnconfirmedSats = result.UnconfirmedSats,
ImmatureSats = result.ImmatureSats, ImmatureSats = result.ImmatureSats,
PendingVerificationSats = result.PendingVerificationSats,
SpendableSats = result.SpendableSats,
NextReceiveIndex = result.NextReceiveIndex, NextReceiveIndex = result.NextReceiveIndex,
NextChangeIndex = result.NextChangeIndex, NextChangeIndex = result.NextChangeIndex,
History = [.. result.History], History = [.. result.History],
@@ -160,8 +163,9 @@ static async Task<int> Sync(string[] o)
}; };
WalletStore.Save(doc, path, Opt(o, "--password")); WalletStore.Save(doc, path, Opt(o, "--password"));
Console.WriteLine($"Balance: {CoinAmount.Format(result.ConfirmedSats - result.ImmatureSats, account.Profile.CoinUnit)} spendable" Console.WriteLine($"Balance: {CoinAmount.Format(result.SpendableSats, account.Profile.CoinUnit)} spendable"
+ (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} maturing (not spendable)" : "") + (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} maturing (not spendable)" : "")
+ (result.PendingVerificationSats != 0 ? $" + {CoinAmount.Format(result.PendingVerificationSats)} awaiting SPV verification (not spendable)" : "")
+ (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} pending confirmation (not spendable)" : "")); + (result.UnconfirmedSats != 0 ? $" + {CoinAmount.Format(result.UnconfirmedSats)} pending confirmation (not spendable)" : ""));
Console.WriteLine($"History ({result.History.Count}):"); Console.WriteLine($"History ({result.History.Count}):");
foreach (var tx in result.History) foreach (var tx in result.History)
+4 -2
View File
@@ -48,8 +48,9 @@ public static class ChainProfiles
new ServerEndpoint("89.117.149.130", 50001, 50002), new ServerEndpoint("89.117.149.130", 50001, 50002),
], ],
// Real mainnet [height, hash, bits], pulled from a fully-synced palladiumd via // Real mainnet [height, hash, bits], pulled from a fully-synced palladiumd via
// RPC (getblockhash/getblockheader), spaced every 20,000 blocks (~660h) plus one // RPC (getblockhash/getblockheader) or verified against a trusted indexing server,
// recent block. Anchors WalletSynchronizer's header-chain verification (§7.3): // spaced every 20,000 blocks (~660h) plus recent ones added at each release to keep
// the header-anchoring walk short. Anchors WalletSynchronizer's header-chain verification (§7.3):
// bounds how far back a forged header chain must be walked to be caught, since // bounds how far back a forged header chain must be walked to be caught, since
// this LWMA chain cannot be PoW-validated locally (SkipPowValidation). // this LWMA chain cannot be PoW-validated locally (SkipPowValidation).
Checkpoints = Checkpoints =
@@ -78,6 +79,7 @@ public static class ChainProfiles
new Checkpoint(440000, "00000000000001b09d7da81403a9b383a734305a8783cb3a0dbe009edea26a95", 0x1a0216c4), new Checkpoint(440000, "00000000000001b09d7da81403a9b383a734305a8783cb3a0dbe009edea26a95", 0x1a0216c4),
new Checkpoint(460000, "00000000000000ecc7413f638bfe7be80a36bacab858ce9a814f194d9df526d5", 0x1a07dd8f), new Checkpoint(460000, "00000000000000ecc7413f638bfe7be80a36bacab858ce9a814f194d9df526d5", 0x1a07dd8f),
new Checkpoint(468800, "000000000000052c61652eed72b441d8c1f1926710a8d691d101be4961dba105", 0x1a1838ee), new Checkpoint(468800, "000000000000052c61652eed72b441d8c1f1926710a8d691d101be4961dba105", 0x1a1838ee),
new Checkpoint(475124, "00000000000009e66da1e1a430fd1932aa75bf513053df088764545d941f13ca", 0x1a1a98cb),
], ],
}; };
+20
View File
@@ -11,6 +11,9 @@ public readonly record struct UnspentItem(string TxHash, int TxPos, long ValueSa
/// <summary>Merkle proof (blockchain.transaction.get_merkle).</summary> /// <summary>Merkle proof (blockchain.transaction.get_merkle).</summary>
public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle); public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle);
/// <summary>Range of headers (blockchain.block.headers): Hex is Count concatenated 80-byte headers.</summary>
public readonly record struct HeaderRangeResponse(int Count, string Hex);
/// <summary>Chain tip notified by blockchain.headers.subscribe.</summary> /// <summary>Chain tip notified by blockchain.headers.subscribe.</summary>
public readonly record struct ChainTip(int Height, string HeaderHex); public readonly record struct ChainTip(int Height, string HeaderHex);
@@ -82,6 +85,23 @@ public static class ElectrumApi
return r.GetString()!; return r.GetString()!;
} }
/// <summary>
/// Range fetch (blockchain.block.headers): one RPC returns up to <paramref name="count"/>
/// concatenated 80-byte headers starting at <paramref name="startHeight"/>. Used to anchor
/// a tx height to a hardcoded checkpoint (§7.3) without one round-trip per header — critical
/// on high-latency links (mobile) where thousands of serial single-header calls stall sync.
/// The server may return fewer than requested (see <see cref="HeaderRangeResponse.Count"/>);
/// callers must loop until the full range is covered.
/// </summary>
public static async Task<HeaderRangeResponse> GetBlockHeadersAsync(this ElectrumClient c,
int startHeight, int count, CancellationToken ct = default)
{
var r = await c.RequestAsync("blockchain.block.headers", ct, startHeight, count);
return new HeaderRangeResponse(
r.GetProperty("count").GetInt32(),
r.GetProperty("hex").GetString()!);
}
public static async Task<string> BroadcastAsync(this ElectrumClient c, string rawTxHex, public static async Task<string> BroadcastAsync(this ElectrumClient c, string rawTxHex,
CancellationToken ct = default) CancellationToken ct = default)
{ {
+168 -28
View File
@@ -29,6 +29,23 @@ public sealed class SyncResult
/// <see cref="ConfirmedSats"/>. /// <see cref="ConfirmedSats"/>.
/// </summary> /// </summary>
public required long ImmatureSats { get; init; } public required long ImmatureSats { get; init; }
/// <summary>
/// Confirmed and past its threshold, but not yet spendable because its Merkle proof
/// hasn't been checked yet (§7.4 progressive verification catching up in the background).
/// Subset of <see cref="ConfirmedSats"/> — NOT disjoint from <see cref="ImmatureSats"/>
/// (an immature coinbase can also be unverified), so never subtract both from
/// <see cref="ConfirmedSats"/> to get a spendable total — use <see cref="SpendableSats"/>.
/// </summary>
public required long PendingVerificationSats { get; init; }
/// <summary>
/// Sum of UTXOs that actually pass <see cref="Wallet.UtxoSpendability.IsSpendable"/> right
/// now — the true spendable balance. Computed directly from the same gate coin selection
/// uses, rather than by subtracting <see cref="ImmatureSats"/>/<see cref="PendingVerificationSats"/>
/// from <see cref="ConfirmedSats"/>, since those two can overlap.
/// </summary>
public required long SpendableSats { get; init; }
public required int NextReceiveIndex { get; init; } public required int NextReceiveIndex { get; init; }
public required int NextChangeIndex { get; init; } public required int NextChangeIndex { get; init; }
public required IReadOnlyList<CachedTx> History { get; init; } public required IReadOnlyList<CachedTx> History { get; init; }
@@ -46,14 +63,37 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
/// <summary>Human-readable progress (for CLI and GUI status bar).</summary> /// <summary>Human-readable progress (for CLI and GUI status bar).</summary>
public event Action<string>? Progress; public event Action<string>? Progress;
/// <summary>
/// Fires with a fresh, self-consistent snapshot as soon as transaction downloads finish
/// (Merkle proofs may still be pending — see <see cref="CachedTx.Verified"/>/
/// <see cref="CachedUtxo.Verified"/>) and again periodically as background verification
/// progresses (§7.4). The wallet is usable after the first firing instead of waiting for
/// every historical proof to be checked; <see cref="SyncOnceAsync"/>'s returned Task still
/// only completes once verification is fully done, for callers that need the final state.
/// </summary>
public event Action<SyncResult>? PartialResult;
private readonly ConcurrentDictionary<string, Transaction> _txCache = new(); private readonly ConcurrentDictionary<string, Transaction> _txCache = new();
private readonly Dictionary<string, int> _verifiedAtHeight = [];
// Concurrent: written incrementally by individual merkle-verification tasks as they
// complete (§7.4 progressive verification), not just once after they all finish.
private readonly ConcurrentDictionary<string, int> _verifiedAtHeight = new();
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new(); private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
// checkpoint height -> highest height already proven to hash-chain back to it // checkpoint height -> highest height already proven to hash-chain back to it
// (in-memory only: cheap to recompute from _headerFetches, no need to persist). // (in-memory only: cheap to recompute from _headerFetches, no need to persist).
private readonly ConcurrentDictionary<int, int> _anchoredUpTo = new(); private readonly ConcurrentDictionary<int, int> _anchoredUpTo = new();
// Serializes header-range downloads: concurrent AnchorToCheckpointAsync calls (one per tx
// being verified) would otherwise race on overlapping ranges and issue duplicate range
// requests. Fetches are network-bound and few (batches of up to 2016 headers), so
// serializing them costs nothing that matters.
private readonly SemaphoreSlim _headerRangeLock = new(1, 1);
// Max headers requested per blockchain.block.headers call. The server may return fewer
// (its own configured cap) — FetchHeaderRangeAsync loops on the actual count returned.
private const int HeaderBatchSize = 2016;
// Indices known from the previous sync: used by ScanChainAsync for incremental // Indices known from the previous sync: used by ScanChainAsync for incremental
// discovery — already-used addresses are fetched in a single burst instead of // discovery — already-used addresses are fetched in a single burst instead of
// sequential batches, reducing round-trips from O(used/gapLimit) to O(1). // sequential batches, reducing round-trips from O(used/gapLimit) to O(1).
@@ -162,7 +202,15 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
foreach (var item in historyByAddress.Values.SelectMany(h => h)) foreach (var item in historyByAddress.Values.SelectMany(h => h))
txHeights[item.TxHash] = item.Height; txHeights[item.TxHash] = item.Height;
// 4+5. Download missing transactions and verify Merkle proofs in parallel. // 4. Download missing transactions — needed to compute amounts/UTXOs locally.
// Merkle-proof verification (5) is deliberately NOT awaited together with this: on a
// wallet with thousands of transactions, downloads finish in seconds while proofs can
// take much longer over a high-latency link, and the wallet has everything it needs to
// show balance/history the moment downloads are done. Verification then continues in
// the background (§7.4 progressive verification), firing PartialResult as proofs land,
// while coin selection stays locked out of any UTXO until its own proof is checked
// (CachedUtxo.Verified, enforced in UtxoSpendability.IsSpendable) — a malicious server
// cannot get a fabricated balance spent just because it was shown early.
var network = PalladiumNetworks.For(account.Profile.Kind); var network = PalladiumNetworks.For(account.Profile.Kind);
var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList(); var missing = txHeights.Keys.Where(txid => !_txCache.ContainsKey(txid)).ToList();
var toVerify = txHeights var toVerify = txHeights
@@ -171,46 +219,81 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
.ToList(); .ToList();
if (missing.Count > 0 || toVerify.Count > 0) if (missing.Count > 0 || toVerify.Count > 0)
{
Progress?.Invoke($"downloading {missing.Count} txs, verifying {toVerify.Count} proofs…"); Progress?.Invoke($"downloading {missing.Count} txs, verifying {toVerify.Count} proofs…");
var dlDone = 0;
var merkDone = 0;
var dlTasks = missing.Select(txid => RetryOnBusyAsync(async () => var dlDone = 0;
await Task.WhenAll(missing.Select(txid => RetryOnBusyAsync(async () =>
{ {
var raw = await client.GetTransactionAsync(txid, ct); var raw = await client.GetTransactionAsync(txid, ct);
_txCache[txid] = Transaction.Parse(raw, network); _txCache[txid] = Transaction.Parse(raw, network);
var n = Interlocked.Increment(ref dlDone); var n = Interlocked.Increment(ref dlDone);
if (n % 50 == 0 || n == missing.Count) if (n % 50 == 0 || n == missing.Count)
Progress?.Invoke($"tx {n}/{missing.Count}, proofs {merkDone}/{toVerify.Count}…"); Progress?.Invoke($"tx {n}/{missing.Count}, proofs 0/{toVerify.Count}…");
}, ct)); }, ct)));
SyncResult BuildSnapshot() =>
BuildResult(tip.Height, tracked, historyByAddress, txHeights, nextReceive, nextChange);
PartialResult?.Invoke(BuildSnapshot());
var merkDone = 0;
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () => var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
{ {
var (txid, height) = kv; var (txid, height) = kv;
var proofTask = client.GetMerkleAsync(txid, height, ct); var proofTask = client.GetMerkleAsync(txid, height, ct);
var headerTask = _headerFetches.GetOrAdd(height, // Anchor first: on a checkpointed height this fills _headerFetches[height]
h => client.GetBlockHeaderAsync(h, ct)); // via the batched range fetch (§7.3), so the header lookup below is a cache
var proof = await proofTask; // hit instead of a second individual blockchain.block.header RPC per tx —
var header = BlockHeaderInfo.Parse(await headerTask); // halves round-trips for this stage on mainnet, where it matters most on
// high-latency mobile links. Falls back to an individual fetch when no
// checkpoint covers this height (testnet/regtest today).
await AnchorToCheckpointAsync(height, ct); await AnchorToCheckpointAsync(height, ct);
var headerHex = await _headerFetches.GetOrAdd(height, h => client.GetBlockHeaderAsync(h, ct));
var header = BlockHeaderInfo.Parse(headerHex);
var proof = await proofTask;
if (!MerkleProof.Verify( if (!MerkleProof.Verify(
uint256.Parse(txid), proof.Pos, uint256.Parse(txid), proof.Pos,
proof.Merkle.Select(uint256.Parse), header.MerkleRoot)) proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
throw new SpvVerificationException( throw new SpvVerificationException(
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy."); $"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
_verifiedAtHeight[txid] = height;
var n = Interlocked.Increment(ref merkDone); var n = Interlocked.Increment(ref merkDone);
if (n % 50 == 0 || n == toVerify.Count) if (n % 50 == 0 || n == toVerify.Count)
Progress?.Invoke($"tx {dlDone}/{missing.Count}, proofs {n}/{toVerify.Count}…"); Progress?.Invoke($"tx {missing.Count}/{missing.Count}, proofs {n}/{toVerify.Count}…");
}, ct)); if (n % PartialResultBatchSize == 0)
PartialResult?.Invoke(BuildSnapshot());
}, ct)).ToList();
await Task.WhenAll(dlTasks.Concat(merkTasks)); if (merkTasks.Count > 0)
foreach (var (txid, height) in toVerify) await Task.WhenAll(merkTasks);
_verifiedAtHeight[txid] = height;
return BuildSnapshot();
} }
// Rebuilding the full snapshot (UTXOs/history/address rows) is O(wallet size); firing it on
// every single verified proof would make the background verification phase itself O(n²) for
// a wallet with thousands of transactions. Batching keeps "verified" badges catching up
// visibly without that cost.
private const int PartialResultBatchSize = 200;
private bool IsTxVerified(string txid, int height) =>
height <= 0 || (_verifiedAtHeight.TryGetValue(txid, out var vh) && vh == height);
/// <summary>
/// Assembles a <see cref="SyncResult"/> from the current state of <see cref="_txCache"/> and
/// <see cref="_verifiedAtHeight"/>. Callable multiple times per sync (§7.4): once as soon as
/// transaction downloads finish (proofs still pending), and again as verification progresses,
/// each time reflecting whichever transactions have been proof-checked so far.
/// </summary>
private SyncResult BuildResult(
int tipHeight,
List<TrackedAddress> tracked,
Dictionary<string, IReadOnlyList<HistoryItem>> historyByAddress,
Dictionary<string, int> txHeights,
int nextReceive,
int nextChange)
{
var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]); var transactions = txHeights.Keys.ToDictionary(txid => txid, txid => _txCache[txid]);
var verified = txHeights.ToDictionary(kv => kv.Key, kv => kv.Value > 0);
// 6. Local UTXO reconstruction. // 6. Local UTXO reconstruction.
var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t); var byScript = tracked.ToDictionary(t => t.ScriptPubKey, t => t);
@@ -222,6 +305,8 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
var utxos = new List<CachedUtxo>(); var utxos = new List<CachedUtxo>();
foreach (var (txid, tx) in transactions) foreach (var (txid, tx) in transactions)
{ {
var height = txHeights[txid];
var verifiedTx = IsTxVerified(txid, height);
for (var vout = 0; vout < tx.Outputs.Count; vout++) for (var vout = 0; vout < tx.Outputs.Count; vout++)
{ {
var output = tx.Outputs[vout]; var output = tx.Outputs[vout];
@@ -237,8 +322,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
Address = addr.Address.ToString(), Address = addr.Address.ToString(),
IsChange = addr.IsChange, IsChange = addr.IsChange,
AddressIndex = addr.Index, AddressIndex = addr.Index,
Height = txHeights[txid], Height = height,
IsCoinbase = tx.IsCoinBase, IsCoinbase = tx.IsCoinBase,
Verified = verifiedTx,
}); });
} }
} }
@@ -247,6 +333,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
var history = new List<CachedTx>(); var history = new List<CachedTx>();
foreach (var (txid, tx) in transactions) foreach (var (txid, tx) in transactions)
{ {
var height = txHeights[txid];
var received = tx.Outputs var received = tx.Outputs
.Where(o => byScript.ContainsKey(o.ScriptPubKey)) .Where(o => byScript.ContainsKey(o.ScriptPubKey))
.Sum(o => o.Value.Satoshi); .Sum(o => o.Value.Satoshi);
@@ -257,9 +344,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
history.Add(new CachedTx history.Add(new CachedTx
{ {
Txid = txid, Txid = txid,
Height = txHeights[txid], Height = height,
DeltaSats = received - sentSats, DeltaSats = received - sentSats,
Verified = verified[txid], Verified = IsTxVerified(txid, height),
}); });
} }
history.Sort((a, b) => history.Sort((a, b) =>
@@ -286,12 +373,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
return new SyncResult return new SyncResult
{ {
TipHeight = tip.Height, TipHeight = tipHeight,
ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats), ConfirmedSats = utxos.Where(u => u.Height > 0).Sum(u => u.ValueSats),
UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats), UnconfirmedSats = utxos.Where(u => u.Height <= 0).Sum(u => u.ValueSats),
ImmatureSats = utxos.Where(u => ImmatureSats = utxos.Where(u =>
u.Height > 0 && u.Confirmations(tip.Height) < u.RequiredConfirmations(account.Profile)) u.Height > 0 && u.Confirmations(tipHeight) < u.RequiredConfirmations(account.Profile))
.Sum(u => u.ValueSats), .Sum(u => u.ValueSats),
PendingVerificationSats = utxos.Where(u => u.Height > 0 && !u.Verified).Sum(u => u.ValueSats),
SpendableSats = utxos.Where(u => u.IsSpendable(account.Profile, tipHeight)).Sum(u => u.ValueSats),
NextReceiveIndex = nextReceive, NextReceiveIndex = nextReceive,
NextChangeIndex = nextChange, NextChangeIndex = nextChange,
History = history, History = history,
@@ -325,9 +414,11 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
if (_anchoredUpTo.TryGetValue(cp.Height, out var anchoredTo) && anchoredTo >= height) if (_anchoredUpTo.TryGetValue(cp.Height, out var anchoredTo) && anchoredTo >= height)
return; return;
var headers = await Task.WhenAll(Enumerable.Range(cp.Height, height - cp.Height + 1) await FetchHeaderRangeAsync(cp.Height, height, ct);
.Select(async h => BlockHeaderInfo.Parse(
await _headerFetches.GetOrAdd(h, hh => client.GetBlockHeaderAsync(hh, ct))))); var headers = Enumerable.Range(cp.Height, height - cp.Height + 1)
.Select(h => BlockHeaderInfo.Parse(_headerFetches[h].Result))
.ToArray();
if (!headers[0].MatchesCheckpoint(cp)) if (!headers[0].MatchesCheckpoint(cp))
throw new SpvVerificationException( throw new SpvVerificationException(
@@ -341,6 +432,41 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
_anchoredUpTo.AddOrUpdate(cp.Height, height, (_, existing) => Math.Max(existing, height)); _anchoredUpTo.AddOrUpdate(cp.Height, height, (_, existing) => Math.Max(existing, height));
} }
/// <summary>
/// Ensures every height in [<paramref name="fromHeight"/>, <paramref name="toHeightInclusive"/>]
/// is present in <see cref="_headerFetches"/>, downloading gaps with
/// blockchain.block.headers (§7.3) instead of one blockchain.block.header call per height.
/// </summary>
private async Task FetchHeaderRangeAsync(int fromHeight, int toHeightInclusive, CancellationToken ct)
{
await _headerRangeLock.WaitAsync(ct);
try
{
var h = fromHeight;
while (h <= toHeightInclusive)
{
if (_headerFetches.ContainsKey(h)) { h++; continue; }
var requested = Math.Min(HeaderBatchSize, toHeightInclusive - h + 1);
var range = await client.GetBlockHeadersAsync(h, requested, ct);
if (range.Count == 0)
throw new SpvVerificationException(
$"Server returned no headers starting at height {h}: server is not trustworthy.");
for (var i = 0; i < range.Count; i++)
{
var headerHex = range.Hex.Substring(i * BlockHeaderInfo.Size * 2, BlockHeaderInfo.Size * 2);
_headerFetches.TryAdd(h + i, Task.FromResult(headerHex));
}
h += range.Count;
}
}
finally
{
_headerRangeLock.Release();
}
}
/// <summary> /// <summary>
/// Scans one chain (receiving or change). /// Scans one chain (receiving or change).
/// ///
@@ -414,7 +540,12 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
return (firstUnused, tracked, history); return (firstUnused, tracked, history);
} }
private static async Task RetryOnBusyAsync(Func<Task> op, CancellationToken ct) // Counts "server busy" retries across the whole sync: surfaced via Progress so a slow
// sync can be diagnosed as server-side throttling (exponential backoff eating the time)
// rather than guessed at from wall-clock numbers alone.
private int _busyRetries;
private async Task RetryOnBusyAsync(Func<Task> op, CancellationToken ct)
{ {
var delay = 200; var delay = 200;
for (var attempt = 0; ; attempt++) for (var attempt = 0; ; attempt++)
@@ -423,13 +554,14 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
catch (ElectrumServerException ex) catch (ElectrumServerException ex)
when (IsBusy(ex) && attempt < 7) when (IsBusy(ex) && attempt < 7)
{ {
ReportBusyRetry(delay);
await Task.Delay(delay, ct); await Task.Delay(delay, ct);
delay = Math.Min(delay * 2, 5_000); delay = Math.Min(delay * 2, 5_000);
} }
} }
} }
private static async Task<T> RetryOnBusyAsync<T>(Func<Task<T>> op, CancellationToken ct) private async Task<T> RetryOnBusyAsync<T>(Func<Task<T>> op, CancellationToken ct)
{ {
var delay = 200; var delay = 200;
for (var attempt = 0; ; attempt++) for (var attempt = 0; ; attempt++)
@@ -438,12 +570,20 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
catch (ElectrumServerException ex) catch (ElectrumServerException ex)
when (IsBusy(ex) && attempt < 7) when (IsBusy(ex) && attempt < 7)
{ {
ReportBusyRetry(delay);
await Task.Delay(delay, ct); await Task.Delay(delay, ct);
delay = Math.Min(delay * 2, 5_000); delay = Math.Min(delay * 2, 5_000);
} }
} }
} }
private void ReportBusyRetry(int delayMs)
{
var n = Interlocked.Increment(ref _busyRetries);
if (n == 1 || n % 20 == 0)
Progress?.Invoke($"server busy, retry #{n} (waiting {delayMs}ms)…");
}
private static bool IsBusy(ElectrumServerException ex) => private static bool IsBusy(ElectrumServerException ex) =>
ex.Message.Contains("-102") || ex.Message.Contains("-102") ||
ex.Message.Contains("-101") || ex.Message.Contains("-101") ||
+24
View File
@@ -98,6 +98,16 @@ public sealed class SyncCache
/// <summary>Confirmed but not yet spendable (coinbase immature or under min confirmations). Subset of ConfirmedSats.</summary> /// <summary>Confirmed but not yet spendable (coinbase immature or under min confirmations). Subset of ConfirmedSats.</summary>
public long ImmatureSats { get; set; } public long ImmatureSats { get; set; }
/// <summary>
/// Confirmed, past its threshold, but its Merkle proof isn't checked yet. Subset of
/// ConfirmedSats — NOT disjoint from ImmatureSats, so never subtract both from
/// ConfirmedSats to get a spendable total; use SpendableSats instead.
/// </summary>
public long PendingVerificationSats { get; set; }
/// <summary>The actual spendable balance: sum of UTXOs passing UtxoSpendability.IsSpendable.</summary>
public long SpendableSats { get; set; }
public int NextReceiveIndex { get; set; } public int NextReceiveIndex { get; set; }
public int NextChangeIndex { get; set; } public int NextChangeIndex { get; set; }
public List<CachedTx> History { get; set; } = []; public List<CachedTx> History { get; set; } = [];
@@ -141,6 +151,13 @@ public sealed class CachedTx
public required string Txid { get; set; } public required string Txid { get; set; }
public int Height { get; set; } public int Height { get; set; }
public long DeltaSats { get; set; } public long DeltaSats { get; set; }
/// <summary>
/// True once this tx's Merkle proof has actually been checked against a
/// checkpoint-anchored header (not merely "confirmed" — a confirmed tx can still
/// be pending its own proof while background verification catches up). Always
/// false for unconfirmed (mempool) entries, which have no proof to check yet.
/// </summary>
public bool Verified { get; set; } public bool Verified { get; set; }
} }
@@ -155,4 +172,11 @@ public sealed class CachedUtxo
public int Height { get; set; } public int Height { get; set; }
public bool IsCoinbase { get; set; } public bool IsCoinbase { get; set; }
public bool Frozen { get; set; } public bool Frozen { get; set; }
/// <summary>
/// True once the owning tx's Merkle proof has been checked (see <see cref="CachedTx.Verified"/>).
/// Gates spendability in <see cref="Wallet.UtxoSpendability.IsSpendable"/>: a server can report a
/// fake confirmed UTXO before its proof is checked, so coin selection must never touch it early.
/// </summary>
public bool Verified { get; set; }
} }
+8
View File
@@ -37,4 +37,12 @@ public static class WalletStore
File.WriteAllText(tmp, content); File.WriteAllText(tmp, content);
File.Move(tmp, path, overwrite: true); File.Move(tmp, path, overwrite: true);
} }
/// <summary>
/// Same as <see cref="Save"/> but with the JSON serialization and disk write off the
/// calling thread — for callers on a UI thread saving a large cache (thousands of cached
/// transactions/headers), where the synchronous version would block the UI.
/// </summary>
public static Task SaveAsync(WalletDocument doc, string path, string? password = null) =>
Task.Run(() => Save(doc, path, password));
} }
+118 -7
View File
@@ -29,6 +29,8 @@ public sealed class BuiltTransaction
/// </summary> /// </summary>
public sealed class TransactionFactory(IWalletAccount account) public sealed class TransactionFactory(IWalletAccount account)
{ {
private const int MaxStandardTransactionVirtualSize = 100_000;
private Network Network => PalladiumNetworks.For(account.Profile.Kind); private Network Network => PalladiumNetworks.For(account.Profile.Kind);
/// <summary> /// <summary>
@@ -68,9 +70,9 @@ public sealed class TransactionFactory(IWalletAccount account)
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList(); u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
if (immature.Count > 0) if (immature.Count > 0)
{ {
var best = immature.Max(u => u.Confirmations(tipHeight)); var bestImmatureConf = immature.Max(u => u.Confirmations(tipHeight));
var threshold = profile.CoinbaseMaturity + 1; var threshold = profile.CoinbaseMaturity + 1;
reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({best}/{threshold} confirmations). "); reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({bestImmatureConf}/{threshold} confirmations). ");
} }
var underConf = utxos.Where(u => var underConf = utxos.Where(u =>
@@ -78,20 +80,96 @@ public sealed class TransactionFactory(IWalletAccount account)
u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList(); u.Confirmations(tipHeight) < u.RequiredConfirmations(profile)).ToList();
if (underConf.Count > 0) if (underConf.Count > 0)
{ {
var best = underConf.Max(u => u.Confirmations(tipHeight)); var bestUnderConf = underConf.Max(u => u.Confirmations(tipHeight));
reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({best} so far). "); reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({bestUnderConf} so far). ");
} }
var unverified = utxos.Where(u =>
!u.Frozen && u.Height > 0 && !u.Verified &&
u.Confirmations(tipHeight) >= u.RequiredConfirmations(profile)).ToList();
if (unverified.Count > 0)
reasons.Append($"{unverified.Count} output(s) confirmed but still awaiting Merkle-proof verification " +
$"({CoinAmount.Format(unverified.Sum(u => u.ValueSats))}). ");
throw new WalletSpendException(reasons.Length > 0 throw new WalletSpendException(reasons.Length > 0
? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}" ? $"No spendable UTXOs: {reasons.ToString().TrimEnd()}"
: "No spendable UTXOs selected."); : "No spendable UTXOs selected.");
} }
var coins = spendable.Select(u => new Coin( var ordered = spendable
.OrderByDescending(u => u.ValueSats)
.ThenBy(u => u.Height)
.ThenBy(u => u.Txid, StringComparer.Ordinal)
.ThenBy(u => u.Vout)
.ToList();
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
if (sendAll)
{
try
{
return BuildWithSelectedUtxos(
ordered, transactions, destination, amountSats, feeRate, changeIndex, sendAll: true, totalSpendableCount: ordered.Count);
}
catch (TransactionTooLargeException ex)
{
throw new WalletSpendException(ex.Message);
}
}
NotEnoughFundsException? lastInsufficientFunds = null;
TransactionTooLargeException? tooLarge = null;
BuiltTransaction? best = null;
var low = 1;
var high = ordered.Count;
while (low <= high)
{
var count = low + ((high - low) / 2);
try
{
best = BuildWithSelectedUtxos(
ordered.Take(count).ToList(), transactions, destination, amountSats, feeRate, changeIndex,
sendAll: false, totalSpendableCount: ordered.Count);
high = count - 1;
}
catch (NotEnoughFundsException ex)
{
lastInsufficientFunds = ex;
low = count + 1;
}
catch (TransactionTooLargeException ex)
{
tooLarge = ex;
high = count - 1;
}
}
if (best is not null)
return best;
if (tooLarge is not null)
throw new WalletSpendException(tooLarge.Message);
throw new WalletSpendException(lastInsufficientFunds is null
? "Insufficient funds."
: $"Insufficient funds: {lastInsufficientFunds.Message}");
}
private BuiltTransaction BuildWithSelectedUtxos(
IReadOnlyList<CachedUtxo> selectedUtxos,
IReadOnlyDictionary<string, Transaction> transactions,
BitcoinAddress destination,
long amountSats,
FeeRate feeRate,
int changeIndex,
bool sendAll,
int totalSpendableCount)
{
var coins = selectedUtxos.Select(u => new Coin(
new OutPoint(uint256.Parse(u.Txid), (uint)u.Vout), new OutPoint(uint256.Parse(u.Txid), (uint)u.Vout),
transactions[u.Txid].Outputs[u.Vout])).ToList(); transactions[u.Txid].Outputs[u.Vout])).ToList();
var feeRate = new FeeRate(Money.Satoshis(feeRateSatPerVByte * 1000m), 1000);
var builder = Network.CreateTransactionBuilder(); var builder = Network.CreateTransactionBuilder();
builder.SetVersion(2); builder.SetVersion(2);
// RBF sequence to allow fee bumping (§6.6). // RBF sequence to allow fee bumping (§6.6).
@@ -107,7 +185,7 @@ public sealed class TransactionFactory(IWalletAccount account)
if (!account.IsWatchOnly) if (!account.IsWatchOnly)
{ {
builder.AddKeys(spendable builder.AddKeys(selectedUtxos
.Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex)) .Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex))
.OfType<Key>() .OfType<Key>()
.ToArray()); .ToArray());
@@ -118,11 +196,27 @@ public sealed class TransactionFactory(IWalletAccount account)
{ {
tx = builder.BuildTransaction(sign: !account.IsWatchOnly); tx = builder.BuildTransaction(sign: !account.IsWatchOnly);
} }
catch (NotEnoughFundsException ex) when (ex.Message.Contains("size would be too high", StringComparison.OrdinalIgnoreCase))
{
// NBitcoin's coin selector refuses to assemble a combination over the standard size
// cap itself and reports it through NotEnoughFundsException rather than ever handing
// back an oversized transaction — the GetVirtualSize() check below is unreachable for
// this case and exists only as a defense-in-depth net for other NBitcoin versions.
throw new TransactionTooLargeException(
BuildTooLargeMessage(selectedUtxos.Count, totalSpendableCount, sendAll));
}
catch (NotEnoughFundsException) when (!sendAll)
{
throw;
}
catch (NotEnoughFundsException ex) catch (NotEnoughFundsException ex)
{ {
throw new WalletSpendException($"Insufficient funds: {ex.Message}"); throw new WalletSpendException($"Insufficient funds: {ex.Message}");
} }
if (tx.GetVirtualSize() > MaxStandardTransactionVirtualSize)
throw new TransactionTooLargeException(BuildTooLargeMessage(selectedUtxos.Count, totalSpendableCount, sendAll, tx.GetVirtualSize()));
if (!account.IsWatchOnly) if (!account.IsWatchOnly)
{ {
if (!builder.Verify(tx, out TransactionPolicyError[] errors)) if (!builder.Verify(tx, out TransactionPolicyError[] errors))
@@ -140,6 +234,21 @@ public sealed class TransactionFactory(IWalletAccount account)
}; };
} }
private static string BuildTooLargeMessage(
int selectedInputCount,
int totalSpendableCount,
bool sendAll,
int? actualVirtualSize = null)
{
var prefix = sendAll
? "Send-all cannot fit in one standard transaction"
: "Transaction cannot fit in one standard transaction";
var size = actualVirtualSize is { } vsize ? $"{vsize} vB exceeds" : "Estimated size exceeds";
return $"{prefix}: {size} the {MaxStandardTransactionVirtualSize} vB standard relay limit " +
$"with {selectedInputCount}/{totalSpendableCount} spendable input(s). Send a smaller amount or consolidate in multiple smaller transactions.";
}
private static Money GetFee(Transaction tx, IReadOnlyList<Coin> coins) private static Money GetFee(Transaction tx, IReadOnlyList<Coin> coins)
{ {
var spentOutpoints = tx.Inputs.Select(i => i.PrevOut).ToHashSet(); var spentOutpoints = tx.Inputs.Select(i => i.PrevOut).ToHashSet();
@@ -147,6 +256,8 @@ public sealed class TransactionFactory(IWalletAccount account)
.Sum(c => (Money)c.Amount); .Sum(c => (Money)c.Amount);
return inputSum - tx.Outputs.Sum(o => o.Value); return inputSum - tx.Outputs.Sum(o => o.Value);
} }
private sealed class TransactionTooLargeException(string message) : Exception(message);
} }
/// <summary>Error during transaction construction/signing (funds, policy, parameters).</summary> /// <summary>Error during transaction construction/signing (funds, policy, parameters).</summary>
+55 -4
View File
@@ -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);
}
} }
+8 -2
View File
@@ -18,7 +18,13 @@ public static class UtxoSpendability
public static int Confirmations(this CachedUtxo utxo, int tipHeight) => public static int Confirmations(this CachedUtxo utxo, int tipHeight) =>
utxo.Height <= 0 ? 0 : tipHeight - utxo.Height + 1; utxo.Height <= 0 ? 0 : tipHeight - utxo.Height + 1;
/// <summary>True when the UTXO has met its confirmation threshold and is not frozen.</summary> /// <summary>
/// True when the UTXO has met its confirmation threshold, is not frozen, and its Merkle
/// proof has actually been checked. Without the <see cref="CachedUtxo.Verified"/> gate a
/// malicious server could report a fake confirmed UTXO and have it spent before background
/// verification ever caught the forgery.
/// </summary>
public static bool IsSpendable(this CachedUtxo utxo, ChainProfile profile, int tipHeight) => public static bool IsSpendable(this CachedUtxo utxo, ChainProfile profile, int tipHeight) =>
!utxo.Frozen && utxo.Height > 0 && utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile); !utxo.Frozen && utxo.Height > 0 && utxo.Verified
&& utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile);
} }
@@ -101,6 +101,15 @@ public class WalletSynchronizerTests
Headers.TryGetValue(p[0].GetInt32(), out var hex) Headers.TryGetValue(p[0].GetInt32(), out var hex)
? hex ? hex
: throw new FakeElectrumError(-32600, "no such block")); : throw new FakeElectrumError(-32600, "no such block"));
server.Handle("blockchain.block.headers", p =>
{
var start = p[0].GetInt32();
var count = p[1].GetInt32();
var hexes = new List<string>();
for (var h = start; hexes.Count < count && Headers.TryGetValue(h, out var hex); h++)
hexes.Add(hex);
return new { count = hexes.Count, hex = string.Concat(hexes) };
});
} }
} }
@@ -232,7 +241,10 @@ public class WalletSynchronizerTests
Assert.Equal(0, result.ConfirmedSats); Assert.Equal(0, result.ConfirmedSats);
Assert.Equal(250_000, result.UnconfirmedSats); Assert.Equal(250_000, result.UnconfirmedSats);
var entry = Assert.Single(result.History); var entry = Assert.Single(result.History);
Assert.False(entry.Verified); // Verified means "its Merkle proof was checked" — a mempool tx has no proof to
// check yet, so it's vacuously true; "not confirmed" is signalled by Height <= 0,
// not by Verified (no merkle/header RPCs happen, asserted below).
Assert.True(entry.Verified);
Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle")); Assert.Equal(0, server.CallCount("blockchain.transaction.get_merkle"));
Assert.Equal(0, server.CallCount("blockchain.block.header")); Assert.Equal(0, server.CallCount("blockchain.block.header"));
} }
@@ -318,8 +330,11 @@ public class WalletSynchronizerTests
var result = await new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync(); var result = await new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync();
Assert.Equal(1_000_000, result.ConfirmedSats); Assert.Equal(1_000_000, result.ConfirmedSats);
// 100..105 inclusive = 6 headers fetched to walk the chain back to the checkpoint. // Anchoring runs before the header lookup, so the range call (blockchain.block.headers)
Assert.Equal(6, server.CallCount("blockchain.block.header")); // covering 100..105 back to the checkpoint already includes the tx's own header —
// no separate single-header call needed.
Assert.Equal(0, server.CallCount("blockchain.block.header"));
Assert.Equal(1, server.CallCount("blockchain.block.headers"));
} }
[Fact] [Fact]
@@ -352,12 +367,13 @@ public class WalletSynchronizerTests
await sync.SyncOnceAsync(); // walks and memoizes the anchor up to 105 await sync.SyncOnceAsync(); // walks and memoizes the anchor up to 105
// 103 <= the memoized 105: anchoring must early-return without re-walking, // 103 <= the memoized 105: anchoring must early-return without re-walking,
// so the header call count stays at the 6 of the first walk (103 is cached). // so the header/range call counts stay at those of the first walk (103 is cached).
scenario.Register(tx2, 103, checkpointAccount.GetReceiveAddress(1)); scenario.Register(tx2, 103, checkpointAccount.GetReceiveAddress(1));
var result = await sync.SyncOnceAsync(); var result = await sync.SyncOnceAsync();
Assert.Equal(1_500_000, result.ConfirmedSats); Assert.Equal(1_500_000, result.ConfirmedSats);
Assert.Equal(6, server.CallCount("blockchain.block.header")); Assert.Equal(0, server.CallCount("blockchain.block.header"));
Assert.Equal(1, server.CallCount("blockchain.block.headers"));
} }
[Fact] [Fact]
@@ -426,6 +442,89 @@ public class WalletSynchronizerTests
Assert.Equal(1, server.CallCount("blockchain.block.header")); Assert.Equal(1, server.CallCount("blockchain.block.header"));
} }
// ---- verifica progressiva (§7.4) ----
[Fact]
public async Task PartialResult_arriva_prima_della_proof_poi_il_risultato_finale_e_completamente_verificato()
{
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
// Gate the Merkle-proof response so the download phase can complete (and fire
// PartialResult) well before verification does.
using var gate = new ManualResetEventSlim(false);
server.Handle("blockchain.transaction.get_merkle", p =>
{
gate.Wait();
return new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty<string>() };
});
var sync = new WalletSynchronizer(account, client);
SyncResult? partial = null;
var partialReceived = new TaskCompletionSource();
sync.PartialResult += r => { partial = r; partialReceived.TrySetResult(); };
var syncTask = sync.SyncOnceAsync();
await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.NotNull(partial);
Assert.False(Assert.Single(partial!.History).Verified);
Assert.False(Assert.Single(partial.Utxos).Verified);
Assert.Equal(1_000_000, partial.PendingVerificationSats);
Assert.Equal(1_000_000, partial.ConfirmedSats); // reported confirmed, just not yet proof-checked
gate.Set();
var final = await syncTask;
Assert.True(Assert.Single(final.History).Verified);
Assert.True(Assert.Single(final.Utxos).Verified);
Assert.Equal(0, final.PendingVerificationSats);
}
[Fact]
public async Task Un_coinbase_immaturo_e_non_ancora_verificato_non_produce_un_saldo_spendibile_negativo()
{
// Regression: ImmatureSats and PendingVerificationSats can overlap (an immature
// coinbase can also be unverified) — "ConfirmedSats - ImmatureSats - PendingVerificationSats"
// double-subtracts that overlap and can go negative. SpendableSats must be computed
// directly from IsSpendable instead.
var account = Account();
var scenario = new Scenario { TipHeight = 200 };
// 11 confirmations at tip 200: far below CoinbaseMaturity+1 = 121 — immature.
scenario.Pay(account.GetReceiveAddress(0), 5_000_000_000, height: 190, coinbase: true);
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
using var gate = new ManualResetEventSlim(false);
server.Handle("blockchain.transaction.get_merkle", p =>
{
gate.Wait();
return new { block_height = p[1].GetInt32(), pos = 0, merkle = Array.Empty<string>() };
});
var sync = new WalletSynchronizer(account, client);
var partialReceived = new TaskCompletionSource();
SyncResult? partial = null;
sync.PartialResult += r => { partial = r; partialReceived.TrySetResult(); };
var syncTask = sync.SyncOnceAsync();
await partialReceived.Task.WaitAsync(TimeSpan.FromSeconds(5));
// Immature AND unverified at the same time: both non-spendable subsets are the
// full 5 PLM, but the actual spendable balance must be exactly zero, not negative.
Assert.Equal(5_000_000_000, partial!.ImmatureSats);
Assert.Equal(5_000_000_000, partial.PendingVerificationSats);
Assert.Equal(0, partial.SpendableSats);
gate.Set();
await syncTask;
}
// ---- resilienza ---- // ---- resilienza ----
[Fact] [Fact]
@@ -33,12 +33,53 @@ public class TransactionFactoryTests
{ {
Txid = txid, Vout = 0, ValueSats = sats, Txid = txid, Vout = 0, ValueSats = sats,
Address = account.GetReceiveAddress(0).ToString(), Address = account.GetReceiveAddress(0).ToString(),
IsChange = false, AddressIndex = 0, Height = 100, IsChange = false, AddressIndex = 0, Height = 100, Verified = true,
}, },
}; };
return (utxos, new Dictionary<string, Transaction> { [txid] = funding }); return (utxos, new Dictionary<string, Transaction> { [txid] = funding });
} }
private static void AddFund(
HdAccount account,
List<CachedUtxo> utxos,
Dictionary<string, Transaction> transactions,
int index,
long sats)
{
var funding = Net.CreateTransaction();
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, (uint)index)));
funding.Outputs.Add(Money.Satoshis(sats), account.GetReceiveAddress(index));
var txid = funding.GetHash().ToString();
transactions[txid] = funding;
utxos.Add(new CachedUtxo
{
Txid = txid,
Vout = 0,
ValueSats = sats,
Address = account.GetReceiveAddress(index).ToString(),
IsChange = false,
AddressIndex = index,
Height = 100,
Verified = true,
});
}
[Fact]
public void Un_utxo_confermato_ma_non_ancora_verificato_non_e_spendibile()
{
// Confirmed by the server, but its Merkle proof hasn't been checked yet (progressive
// background verification, §7.4): must never be treated as spendable — otherwise a
// malicious server could get a fabricated balance spent before the forgery is caught.
var account = Account();
var (utxos, txs) = Fund(account, 1_000_000);
utxos[0].Verified = false;
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
utxos, txs, account.GetReceiveAddress(5), amountSats: 400_000,
feeRateSatPerVByte: 2, changeIndex: 0, tipHeight: 100));
Assert.Contains("awaiting Merkle-proof verification", ex.Message);
}
[Fact] [Fact]
public void Una_spesa_firmata_verifica_e_paga_la_fee_attesa() public void Una_spesa_firmata_verifica_e_paga_la_fee_attesa()
{ {
@@ -169,7 +210,7 @@ public class TransactionFactoryTests
{ {
new() { Txid = txid, Vout = 0, ValueSats = 1_000_000, new() { Txid = txid, Vout = 0, ValueSats = 1_000_000,
Address = mainnetAccount.GetReceiveAddress(0).ToString(), Address = mainnetAccount.GetReceiveAddress(0).ToString(),
IsChange = false, AddressIndex = 0, Height = 100, IsCoinbase = false }, IsChange = false, AddressIndex = 0, Height = 100, IsCoinbase = false, Verified = true },
}; };
var txs = new Dictionary<string, Transaction> { [txid] = funding }; var txs = new Dictionary<string, Transaction> { [txid] = funding };
@@ -295,7 +336,7 @@ public class TransactionFactoryTests
{ {
Txid = txid, Vout = 0, ValueSats = 300_000, Txid = txid, Vout = 0, ValueSats = 300_000,
Address = account.GetReceiveAddress(i).ToString(), Address = account.GetReceiveAddress(i).ToString(),
IsChange = false, AddressIndex = i, Height = 100, IsChange = false, AddressIndex = i, Height = 100, Verified = true,
}); });
} }
@@ -309,6 +350,42 @@ public class TransactionFactoryTests
Assert.Contains(built.Transaction.Outputs, o => o.Value.Satoshi == 700_000); Assert.Contains(built.Transaction.Outputs, o => o.Value.Satoshi == 700_000);
} }
[Fact]
public void Automatic_coin_selection_uses_large_utxos_before_dust()
{
var account = Account();
var allUtxos = new List<CachedUtxo>();
var allTxs = new Dictionary<string, Transaction>();
AddFund(account, allUtxos, allTxs, index: 0, sats: 2_000_000);
for (var i = 1; i <= 1_200; i++)
AddFund(account, allUtxos, allTxs, i, sats: 10_000);
var built = new TransactionFactory(account).Build(
allUtxos, allTxs, account.GetReceiveAddress(1_250), amountSats: 500_000,
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100);
Assert.Single(built.Transaction.Inputs);
Assert.True(built.Transaction.GetVirtualSize() < 100_000);
Assert.Contains(built.Transaction.Outputs, o => o.Value.Satoshi == 500_000);
}
[Fact]
public void Spending_more_than_the_standard_input_limit_reports_a_clear_error()
{
var account = Account();
var allUtxos = new List<CachedUtxo>();
var allTxs = new Dictionary<string, Transaction>();
for (var i = 0; i < 1_600; i++)
AddFund(account, allUtxos, allTxs, i, sats: 10_000);
var ex = Assert.Throws<WalletSpendException>(() => new TransactionFactory(account).Build(
allUtxos, allTxs, account.GetReceiveAddress(1_650), amountSats: 15_300_000,
feeRateSatPerVByte: 1, changeIndex: 0, tipHeight: 100));
Assert.Contains("standard relay limit", ex.Message);
Assert.Contains("multiple smaller transactions", ex.Message);
}
[Fact] [Fact]
public void Un_resto_sotto_la_soglia_dust_viene_assorbito_nella_fee() public void Un_resto_sotto_la_soglia_dust_viene_assorbito_nella_fee()
{ {
@@ -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()
{ {