Compare commits
1 Commits
main
..
9971e29b81
| Author | SHA1 | Date | |
|---|---|---|---|
| 9971e29b81 |
-14
@@ -41,20 +41,6 @@ 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
|
||||||
|
|||||||
@@ -205,7 +205,6 @@ 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} \
|
||||||
|
|||||||
@@ -395,8 +395,6 @@ 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.",
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ public partial class MainWindowViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string immatureText = "";
|
private string immatureText = "";
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string verifyingText = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string networkInfo = "";
|
private string networkInfo = "";
|
||||||
|
|
||||||
@@ -256,7 +253,6 @@ 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();
|
||||||
@@ -269,11 +265,7 @@ public partial class MainWindowViewModel
|
|||||||
$"m/{_doc!.AccountPath}/0/{i}"));
|
$"m/{_doc!.AccountPath}/0/{i}"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Not "Confirmed - Immature - PendingVerification": those two can overlap (an
|
BalanceText = Fmt(cache.ConfirmedSats - cache.ImmatureSats);
|
||||||
// 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")}"
|
||||||
@@ -281,20 +273,13 @@ 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)
|
||||||
|
|||||||
@@ -273,34 +273,32 @@ 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;
|
||||||
// Off the UI thread: sync does heavy CPU work (LINQ over thousands of
|
var result = await _synchronizer.SyncOnceAsync(ct);
|
||||||
// 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 cache = new SyncCache { RawTxHex = rawHex, VerifiedAt = verifiedAt, BlockHeaders = blockHeaders };
|
var (rawHex, verifiedAt, blockHeaders) = _synchronizer.ExportCaches(
|
||||||
FillDisplayFields(cache, result);
|
PalladiumNetworks.For(_account.Profile.Kind));
|
||||||
_doc.Cache = cache;
|
_doc.Cache = new SyncCache
|
||||||
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}, " +
|
||||||
@@ -347,44 +345,6 @@ 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
|
||||||
|
|||||||
@@ -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, bool Verified = true);
|
public sealed record HistoryRow(string Conferma, string Importo, string Txid);
|
||||||
|
|
||||||
/// <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(
|
||||||
|
|||||||
@@ -102,8 +102,7 @@ public sealed class TransactionDetailsViewModel
|
|||||||
{
|
{
|
||||||
if (d.Confirmations <= 0)
|
if (d.Confirmations <= 0)
|
||||||
return loc["tx.status.mempool"];
|
return loc["tx.status.mempool"];
|
||||||
var status = $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
|
return $"{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)
|
||||||
|
|||||||
@@ -317,9 +317,6 @@
|
|||||||
<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>
|
||||||
@@ -376,16 +373,13 @@
|
|||||||
<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,*,Auto"
|
<Grid ColumnDefinitions="90,160,*"
|
||||||
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"
|
||||||
@@ -395,9 +389,6 @@
|
|||||||
<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>
|
||||||
|
|||||||
+2
-6
@@ -104,9 +104,8 @@ 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.SpendableSats, account.Profile.CoinUnit)} spendable"
|
Console.WriteLine($"balance: {CoinAmount.Format(cache.ConfirmedSats - cache.ImmatureSats, 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)}");
|
||||||
@@ -150,8 +149,6 @@ 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],
|
||||||
@@ -163,9 +160,8 @@ 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.SpendableSats, account.Profile.CoinUnit)} spendable"
|
Console.WriteLine($"Balance: {CoinAmount.Format(result.ConfirmedSats - result.ImmatureSats, 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)
|
||||||
|
|||||||
@@ -48,9 +48,8 @@ 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) or verified against a trusted indexing server,
|
// RPC (getblockhash/getblockheader), spaced every 20,000 blocks (~660h) plus one
|
||||||
// spaced every 20,000 blocks (~660h) plus recent ones added at each release to keep
|
// recent block. Anchors WalletSynchronizer's header-chain verification (§7.3):
|
||||||
// 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 =
|
||||||
@@ -79,7 +78,6 @@ 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),
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ 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);
|
||||||
|
|
||||||
@@ -85,23 +82,6 @@ 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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,23 +29,6 @@ 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; }
|
||||||
@@ -63,37 +46,14 @@ 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).
|
||||||
@@ -202,15 +162,7 @@ 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. Download missing transactions — needed to compute amounts/UTXOs locally.
|
// 4+5. Download missing transactions and verify Merkle proofs in parallel.
|
||||||
// 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
|
||||||
@@ -219,81 +171,46 @@ 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 dlDone = 0;
|
var dlTasks = missing.Select(txid => RetryOnBusyAsync(async () =>
|
||||||
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() =>
|
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
|
||||||
BuildResult(tip.Height, tracked, historyByAddress, txHeights, nextReceive, nextChange);
|
{
|
||||||
|
var (txid, height) = kv;
|
||||||
|
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
||||||
|
var headerTask = _headerFetches.GetOrAdd(height,
|
||||||
|
h => client.GetBlockHeaderAsync(h, ct));
|
||||||
|
var proof = await proofTask;
|
||||||
|
var header = BlockHeaderInfo.Parse(await headerTask);
|
||||||
|
await AnchorToCheckpointAsync(height, ct);
|
||||||
|
if (!MerkleProof.Verify(
|
||||||
|
uint256.Parse(txid), proof.Pos,
|
||||||
|
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
||||||
|
throw new SpvVerificationException(
|
||||||
|
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
|
||||||
|
var n = Interlocked.Increment(ref merkDone);
|
||||||
|
if (n % 50 == 0 || n == toVerify.Count)
|
||||||
|
Progress?.Invoke($"tx {dlDone}/{missing.Count}, proofs {n}/{toVerify.Count}…");
|
||||||
|
}, ct));
|
||||||
|
|
||||||
PartialResult?.Invoke(BuildSnapshot());
|
await Task.WhenAll(dlTasks.Concat(merkTasks));
|
||||||
|
foreach (var (txid, height) in toVerify)
|
||||||
|
_verifiedAtHeight[txid] = height;
|
||||||
|
}
|
||||||
|
|
||||||
var merkDone = 0;
|
|
||||||
var merkTasks = toVerify.Select(kv => RetryOnBusyAsync(async () =>
|
|
||||||
{
|
|
||||||
var (txid, height) = kv;
|
|
||||||
var proofTask = client.GetMerkleAsync(txid, height, ct);
|
|
||||||
// Anchor first: on a checkpointed height this fills _headerFetches[height]
|
|
||||||
// via the batched range fetch (§7.3), so the header lookup below is a cache
|
|
||||||
// hit instead of a second individual blockchain.block.header RPC per tx —
|
|
||||||
// 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);
|
|
||||||
var headerHex = await _headerFetches.GetOrAdd(height, h => client.GetBlockHeaderAsync(h, ct));
|
|
||||||
var header = BlockHeaderInfo.Parse(headerHex);
|
|
||||||
var proof = await proofTask;
|
|
||||||
if (!MerkleProof.Verify(
|
|
||||||
uint256.Parse(txid), proof.Pos,
|
|
||||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
|
||||||
throw new SpvVerificationException(
|
|
||||||
$"Invalid Merkle proof for {txid} (block {height}): server is not trustworthy.");
|
|
||||||
_verifiedAtHeight[txid] = height;
|
|
||||||
var n = Interlocked.Increment(ref merkDone);
|
|
||||||
if (n % 50 == 0 || n == toVerify.Count)
|
|
||||||
Progress?.Invoke($"tx {missing.Count}/{missing.Count}, proofs {n}/{toVerify.Count}…");
|
|
||||||
if (n % PartialResultBatchSize == 0)
|
|
||||||
PartialResult?.Invoke(BuildSnapshot());
|
|
||||||
}, ct)).ToList();
|
|
||||||
|
|
||||||
if (merkTasks.Count > 0)
|
|
||||||
await Task.WhenAll(merkTasks);
|
|
||||||
|
|
||||||
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);
|
||||||
@@ -305,8 +222,6 @@ 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];
|
||||||
@@ -322,9 +237,8 @@ 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 = height,
|
Height = txHeights[txid],
|
||||||
IsCoinbase = tx.IsCoinBase,
|
IsCoinbase = tx.IsCoinBase,
|
||||||
Verified = verifiedTx,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,7 +247,6 @@ 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);
|
||||||
@@ -344,9 +257,9 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
history.Add(new CachedTx
|
history.Add(new CachedTx
|
||||||
{
|
{
|
||||||
Txid = txid,
|
Txid = txid,
|
||||||
Height = height,
|
Height = txHeights[txid],
|
||||||
DeltaSats = received - sentSats,
|
DeltaSats = received - sentSats,
|
||||||
Verified = IsTxVerified(txid, height),
|
Verified = verified[txid],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
history.Sort((a, b) =>
|
history.Sort((a, b) =>
|
||||||
@@ -373,14 +286,12 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
|
|
||||||
return new SyncResult
|
return new SyncResult
|
||||||
{
|
{
|
||||||
TipHeight = tipHeight,
|
TipHeight = tip.Height,
|
||||||
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(tipHeight) < u.RequiredConfirmations(account.Profile))
|
u.Height > 0 && u.Confirmations(tip.Height) < 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,
|
||||||
@@ -414,11 +325,9 @@ 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;
|
||||||
|
|
||||||
await FetchHeaderRangeAsync(cp.Height, height, ct);
|
var headers = await Task.WhenAll(Enumerable.Range(cp.Height, height - cp.Height + 1)
|
||||||
|
.Select(async h => BlockHeaderInfo.Parse(
|
||||||
var headers = Enumerable.Range(cp.Height, height - cp.Height + 1)
|
await _headerFetches.GetOrAdd(h, hh => client.GetBlockHeaderAsync(hh, ct)))));
|
||||||
.Select(h => BlockHeaderInfo.Parse(_headerFetches[h].Result))
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
if (!headers[0].MatchesCheckpoint(cp))
|
if (!headers[0].MatchesCheckpoint(cp))
|
||||||
throw new SpvVerificationException(
|
throw new SpvVerificationException(
|
||||||
@@ -432,41 +341,6 @@ 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).
|
||||||
///
|
///
|
||||||
@@ -540,12 +414,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
return (firstUnused, tracked, history);
|
return (firstUnused, tracked, history);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Counts "server busy" retries across the whole sync: surfaced via Progress so a slow
|
private static async Task RetryOnBusyAsync(Func<Task> op, CancellationToken ct)
|
||||||
// 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++)
|
||||||
@@ -554,14 +423,13 @@ 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 async Task<T> RetryOnBusyAsync<T>(Func<Task<T>> op, CancellationToken ct)
|
private static 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++)
|
||||||
@@ -570,20 +438,12 @@ 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") ||
|
||||||
|
|||||||
@@ -98,16 +98,6 @@ 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; } = [];
|
||||||
@@ -151,13 +141,6 @@ 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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,11 +155,4 @@ 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; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,12 +37,4 @@ 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));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ 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>
|
||||||
@@ -70,9 +68,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 bestImmatureConf = immature.Max(u => u.Confirmations(tipHeight));
|
var best = 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 ({bestImmatureConf}/{threshold} confirmations). ");
|
reasons.Append($"{immature.Count} coinbase output(s) not yet mature ({best}/{threshold} confirmations). ");
|
||||||
}
|
}
|
||||||
|
|
||||||
var underConf = utxos.Where(u =>
|
var underConf = utxos.Where(u =>
|
||||||
@@ -80,96 +78,20 @@ 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 bestUnderConf = underConf.Max(u => u.Confirmations(tipHeight));
|
var best = underConf.Max(u => u.Confirmations(tipHeight));
|
||||||
reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({bestUnderConf} so far). ");
|
reasons.Append($"{underConf.Count} output(s) need {profile.MinConfirmations} confirmations ({best} 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 ordered = spendable
|
var coins = spendable.Select(u => new Coin(
|
||||||
.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).
|
||||||
@@ -185,7 +107,7 @@ public sealed class TransactionFactory(IWalletAccount account)
|
|||||||
|
|
||||||
if (!account.IsWatchOnly)
|
if (!account.IsWatchOnly)
|
||||||
{
|
{
|
||||||
builder.AddKeys(selectedUtxos
|
builder.AddKeys(spendable
|
||||||
.Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex))
|
.Select(u => account.GetPrivateKey(u.IsChange, u.AddressIndex))
|
||||||
.OfType<Key>()
|
.OfType<Key>()
|
||||||
.ToArray());
|
.ToArray());
|
||||||
@@ -196,27 +118,11 @@ 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))
|
||||||
@@ -234,21 +140,6 @@ 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();
|
||||||
@@ -256,8 +147,6 @@ 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>
|
||||||
|
|||||||
@@ -18,13 +18,7 @@ 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>
|
/// <summary>True when the UTXO has met its confirmation threshold and is not frozen.</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.Verified
|
!utxo.Frozen && utxo.Height > 0 && utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile);
|
||||||
&& utxo.Confirmations(tipHeight) >= utxo.RequiredConfirmations(profile);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,15 +101,6 @@ 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) };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,10 +232,7 @@ 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);
|
||||||
// Verified means "its Merkle proof was checked" — a mempool tx has no proof to
|
Assert.False(entry.Verified);
|
||||||
// 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"));
|
||||||
}
|
}
|
||||||
@@ -330,11 +318,8 @@ 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);
|
||||||
// Anchoring runs before the header lookup, so the range call (blockchain.block.headers)
|
// 100..105 inclusive = 6 headers fetched to walk the chain back to the checkpoint.
|
||||||
// covering 100..105 back to the checkpoint already includes the tx's own header —
|
Assert.Equal(6, server.CallCount("blockchain.block.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]
|
||||||
@@ -367,13 +352,12 @@ 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/range call counts stay at those of the first walk (103 is cached).
|
// so the header call count stays at the 6 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(0, server.CallCount("blockchain.block.header"));
|
Assert.Equal(6, server.CallCount("blockchain.block.header"));
|
||||||
Assert.Equal(1, server.CallCount("blockchain.block.headers"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -442,89 +426,6 @@ 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,53 +33,12 @@ 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, Verified = true,
|
IsChange = false, AddressIndex = 0, Height = 100,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
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()
|
||||||
{
|
{
|
||||||
@@ -210,7 +169,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, Verified = true },
|
IsChange = false, AddressIndex = 0, Height = 100, IsCoinbase = false },
|
||||||
};
|
};
|
||||||
var txs = new Dictionary<string, Transaction> { [txid] = funding };
|
var txs = new Dictionary<string, Transaction> { [txid] = funding };
|
||||||
|
|
||||||
@@ -336,7 +295,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, Verified = true,
|
IsChange = false, AddressIndex = i, Height = 100,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,42 +309,6 @@ 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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user