fix(spv): anchor headers up to a checkpoint above them

With proof of work skipped, chaining headers from a checkpoint up to a
transaction's block proves nothing: a server can fabricate a header and
fake headers after a real checkpoint. The header must now hash-chain up to
the nearest checkpoint at or above its height, whose hash is hardcoded.
This also anchors heights below the first checkpoint. The cached anchor
state is renamed AnchoredDownTo and caches without it are re-verified
once. Blocks above the newest checkpoint remain a documented limit.
See ADR 0040.
This commit is contained in:
davide committed 2026-11-03 14:52:29 +01:00
1 parent c8ba2294f2
commit 4e3d93e167
12 files changed
+277 -87

No files matched your search

+11
View File
@@ -29,6 +29,17 @@ release and covers the full history from the initial commit.
### Security
- **SPV header anchoring fixed (important).** The chain of headers used to be walked *from* the
nearest checkpoint below a transaction's height *up to* it. With proof of work skipped
(LWMA), that proves nothing: a malicious server could fabricate a header for the block of a
fake transaction and chain fake headers after a real checkpoint, and every link would pass.
The anchor now goes the other way: the header must hash-chain *up to* the nearest checkpoint
at or above its height, whose hash is hardcoded, which cannot be forged. This also covers
blocks below the first checkpoint (heights < 20,000), which were previously unanchored. Blocks
above the newest checkpoint still have nothing to anchor to (only the Merkle proof is
checked): add a recent checkpoint at each release. A wallet cache from an older version is
re-verified once (its "verified" claims are not trusted); headers and transactions are
reused. See ADR 0040.
- Private keys are no longer precomputed for every address in the UI. Address rows and the
address detail sheet carry only public data and derivation coordinates; the WIF is derived
when the user reveals it (after the password) and dropped when hidden, when the sheet closes
+2 -2
View File
@@ -6,7 +6,7 @@ Palladium Wallet is a self-custody SPV wallet. It is designed to protect funds a
- Theft of the wallet file at rest (AES-256-GCM encryption with PBKDF2-HMAC-SHA512)
- Memory snooping of private keys after unlock (keys are held only in process memory, never written to disk in plaintext unless the user explicitly disables encryption)
- Fraudulent transaction injection by a malicious server (every confirmed transaction is verified with a Merkle proof against SPV-validated block headers anchored to hardcoded checkpoints)
- Fraudulent transaction injection by a malicious server (every confirmed transaction is verified with a Merkle proof against a block header that hash-chains up to a hardcoded checkpoint; the blocks above the newest checkpoint are the known exception, see the SPV trust model)
It does **not** protect against:
@@ -21,7 +21,7 @@ It does **not** protect against:
This wallet is an SPV client, not a full node. It validates:
- Block headers: `SkipPowValidation` is enabled because LWMA difficulty cannot be recomputed client-side, so proof of work is never checked. Instead, every header used for a Merkle proof is hash-chain-linked (prev-hash) back to the nearest hardcoded checkpoint at or below its height, and that checkpoint's hash must match exactly (`Core/Chain/ChainProfiles.cs`, enforced in `Core/Spv/WalletSynchronizer.AnchorToCheckpointAsync`). Currently populated for mainnet only (every 20,000 blocks); testnet/regtest have no checkpoints yet, so headers there are trusted at face value
- Block headers: `SkipPowValidation` is enabled because LWMA difficulty cannot be recomputed client-side, so proof of work is never checked. Instead, every header used for a Merkle proof is hash-chain-linked (prev-hash) **up to the nearest hardcoded checkpoint at or above its height**, and the header at that checkpoint height must hash to the checkpoint exactly (`Core/Chain/ChainProfiles.cs`, enforced in `Core/Spv/WalletSynchronizer.AnchorToCheckpointAsync`, ADR 0040). The direction matters: with PoW skipped, a chain built *from* a checkpoint *up to* a header proves nothing, because anyone can fabricate such a chain; a chain ending in a known hash cannot be forged without inverting SHA-256. Mainnet checkpoints run every 20,000 blocks from 20,000 (so even the oldest blocks are covered) and are extended at each release. **Known limit:** a transaction in a block *above the highest checkpoint* has no checkpoint to anchor to, so only its Merkle proof against the header the server supplied is checked there; the window is the blocks mined since the last release. Testnet/regtest have no checkpoints yet, so headers there are trusted at face value
- Transaction inclusion in a confirmed block (Merkle branch proof, mandatory for every confirmed transaction — see `Core/Spv/MerkleProof.cs`)
It does **not** validate:
+10 -6
View File
@@ -578,9 +578,11 @@ or servers instead of resetting.
Each sync: gets the chain tip → scans your receive and change address chains (gap limit
20) → downloads your transactions → **verifies a Merkle proof for every confirmed
transaction** against its block header → rebuilds your UTXO set and balances locally.
On mainnet, each block header used for a proof is also chained back (via its previous-block
hash) to the nearest built-in checkpoint, so a server cannot substitute a fabricated header
for a real one. Verified data is cached in the wallet file, so later syncs only fetch what
On mainnet, each block header used for a proof is also chained up (via the previous-block
hashes) to the nearest built-in checkpoint above it, which must match exactly, so a server
cannot substitute a fabricated header for a real one. The blocks mined after the newest
checkpoint (shipped with each release) cannot be anchored this way: for those only the Merkle
proof is checked, which is why updating the wallet regularly matters. Verified data is cached in the wallet file, so later syncs only fetch what
is new — even after a restart. If a proof or the checkpoint chain does not check out, the
sync **aborts** with an explicit "server is not trustworthy" error rather than showing you
unverified data; switch servers.
@@ -593,7 +595,7 @@ resumes from the cache instead of restarting.
transaction requires its own Merkle-proof round trip to the indexing server
(`blockchain.transaction.get_merkle`, one request per transaction — the Electrum-style
protocol has no batched form of this call), plus, on mainnet, chaining the covering block
header back to the nearest hardcoded checkpoint. Sync time therefore scales with the number
header up to the nearest hardcoded checkpoint above it. Sync time therefore scales with the number
of confirmed transactions in the wallet's history, not with wall-clock time since creation.
On **Android**, the first sync is typically slower still than on desktop for the same
wallet: mobile networks add higher round-trip latency and lower sustained throughput than a
@@ -601,7 +603,9 @@ desktop's wired/Wi-Fi connection, and every proof round trip pays that latency i
Every subsequent sync is fast: verified proofs, raw transaction bytes, downloaded block
headers, and the checkpoint hash-chain anchoring state are all persisted into the wallet
file's cache, so a resumed or later sync only fetches and verifies what changed since the
last one — even across an app restart.
last one — even across an app restart. After updating from a version older than the
checkpoint-direction fix (ADR 0040), the first sync re-verifies every cached proof once
(the cached headers and transactions are reused).
**Do not use this wallet as a mining payout address.** Pool or solo mining payouts typically
arrive as many small, frequent transactions, and — because of the per-transaction Merkle
@@ -854,7 +858,7 @@ Reset SSL certificates* — see
| *Wrong password.* | The password does not decrypt the file (or the file was tampered with — the two are indistinguishable by design). | Retry; check keyboard layout/caps lock. If truly lost, restore from seed ([15.2](#152-restoring-step-by-step)). |
| *Wallet already open in another instance of the application.* | Another running process holds this wallet's lock. | Close the other instance (GUI or CLI). |
| *The TLS certificate of host:port has changed…* | Certificate pin mismatch — renewal or interception. | Read [12.2](#122-tls-certificate-pinning-tofu--read-before-fixing-a-certificate-error) **before** resetting certificates. |
| Sync aborts, "server is not trustworthy" | A Merkle proof failed, or (mainnet) a block header did not chain back to a known checkpoint. | Switch to another server. Your local data is intact; the wallet refused the bad data. |
| Sync aborts, "server is not trustworthy" | A Merkle proof failed, or (mainnet) a block header did not chain up to a known checkpoint. | Switch to another server. Your local data is intact; the wallet refused the bad data. |
| *The server host:port is not on the Palladium chain (different genesis or checkpoint)…* | The server answered, but serves another chain (or a fork) than the wallet expects. Nothing was synced from it. | Pick another server; the wallet moves on to the next known one on its own. See [12.3](#123-what-synchronization-actually-does). |
| *Insufficient funds* with a non-zero balance | Funds are unconfirmed, under 6 confirmations, or immature coinbase; or the fee doesn't fit. | See [8.4](#84-why-insufficient-funds-can-appear-despite-a-visible-balance). Wait, or use Send all. |
| Restored wallet shows zero balance | Wrong passphrase, wrong script type, or funds beyond the gap limit. | See [15.2](#152-restoring-step-by-step) — the coins are not lost. |
@@ -1,6 +1,6 @@
# 0006. Anchor headers to hardcoded checkpoints and skip PoW validation
- **Status:** Accepted
- **Status:** Accepted (the anchoring direction is superseded by [0040](0040-anchor-headers-up-to-a-checkpoint.md))
- **Area:** SPV
- **Recorded:** 2026-11-03 (retroactive: reconstructed from code, comments and changelog)
@@ -0,0 +1,32 @@
# 0040. Anchor headers up to a checkpoint above them
- **Status:** Accepted
- **Area:** SPV
- **Supersedes:** the anchoring direction in 0006 (the rest of 0006 stands)
- **Recorded:** 2026-11-03
## Context
ADR 0006 skips proof-of-work validation (LWMA cannot be recomputed by an SPV client) and relies on hardcoded checkpoints. `AnchorToCheckpointAsync` took the nearest checkpoint **at or below** a transaction's height and verified that the headers from the checkpoint **up to** that height were prev-hash linked. With PoW not checked, that chain proves nothing: a hostile server can fabricate a header for the block of a fake transaction (with any Merkle root) and chain fabricated headers after the real checkpoint hash; every link is valid and the Merkle proof then matches the fabricated root. The existing tests built their own synthetic chains, so they could not see it. A related gap (L-01): heights below the first checkpoint had no anchor at all.
## Decision
- Anchor **up to** the nearest checkpoint at or above the height. The headers from the transaction's height to the checkpoint height are downloaded, must be prev-hash linked, and the last one must hash to the hardcoded checkpoint. Forging that needs a SHA-256 preimage, not just a consistent set of fake headers.
- `_anchoredDownTo[checkpointHeight]` records the lowest height already proven to reach that checkpoint. A later, lower height walks only the new segment, whose top is the already-trusted header; a height inside the range is a no-op. Persisted as `SyncCache.AnchoredDownTo`.
- Heights below the first checkpoint are anchored to it (at most one checkpoint interval, 20,000 headers); this closes L-01 without new checkpoints.
- **Above the newest checkpoint** there is nothing to anchor to: only the Merkle proof against the server's header is checked. This is a known, documented limit; it is narrowed by adding a recent checkpoint at every release (taken from a trusted node).
- A wallet cache without `AnchoredDownTo` (written by an older version) is not trusted for `VerifiedAt`: every proof is verified again once. Cached headers and raw transactions are reused. A preloaded anchor is accepted only if its headers are also cached and the recorded height is not above its checkpoint.
## Consequences
- The "headers are anchored to a checkpoint" claim now holds against a fabricating server, for every height up to the newest checkpoint.
- Recent transactions (since the last release's checkpoint) stay protected by the Merkle proof alone. Releases should always ship a fresh checkpoint.
- Downloading up to 20,000 headers (about 1.6 MB) for an old transaction is bounded and cached.
- First sync after upgrading is slower for wallets with many cached proofs (re-verification once).
## References
- `src/Core/Spv/WalletSynchronizer.cs`
- `src/Core/Storage/WalletDocument.cs`
- `tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs`
- Related: 0006, 0036
+1
View File
@@ -43,6 +43,7 @@ One ADR per feature or significant design decision. Format: `template.md`. Workf
| 0037 | [Derive an address private key only when it is revealed](0037-derive-address-private-keys-on-reveal.md) | Security | Accepted |
| 0038 | [CLI password sources and explicit plaintext storage](0038-cli-password-sources-and-explicit-plaintext.md) | Distribution | Accepted |
| 0039 | [Server fee estimates, relay floor and a high-fee warning](0039-server-fee-estimates-and-relay-floor.md) | Wallet | Accepted |
| 0040 | [Anchor headers up to a checkpoint above them](0040-anchor-headers-up-to-a-checkpoint.md) | SPV | Accepted |
ADRs `0002` onwards were written retroactively on 2026-11-03 from the code, comments and changelog.
If one misstates the original reasoning, fix it in a pull request.
@@ -281,7 +281,7 @@ public partial class MainWindowViewModel
_doc.Cache?.NextReceiveIndex ?? 0,
_doc.Cache?.NextChangeIndex ?? 0,
net,
_doc.Cache?.AnchoredUpTo);
_doc.Cache?.AnchoredDownTo);
_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
@@ -299,18 +299,18 @@ public partial class MainWindowViewModel
// 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, anchoredUpTo) = await Task.Run(async () =>
var (result, rawHex, verifiedAt, blockHeaders, anchoredDownTo) = 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, caches.AnchoredUpTo);
return (r, caches.RawTxHex, caches.VerifiedAt, caches.BlockHeaders, caches.AnchoredDownTo);
}, ct);
_lastTransactions = result.Transactions;
var cache = new SyncCache
{
RawTxHex = rawHex, VerifiedAt = verifiedAt, BlockHeaders = blockHeaders,
AnchoredUpTo = anchoredUpTo,
AnchoredDownTo = anchoredDownTo,
};
FillDisplayFields(cache, result);
_doc.Cache = cache;
@@ -532,13 +532,13 @@ public partial class MainWindowViewModel
try
{
var net = PalladiumNetworks.For(_account.Profile.Kind);
var (rawHex, verifiedAt, blockHeaders, anchoredUpTo) = _synchronizer.ExportCaches(net);
var (rawHex, verifiedAt, blockHeaders, anchoredDownTo) = _synchronizer.ExportCaches(net);
if (rawHex.Count == 0 && verifiedAt.Count == 0)
return;
(_doc.Cache ??= new SyncCache()).RawTxHex = rawHex;
_doc.Cache.VerifiedAt = verifiedAt;
_doc.Cache.BlockHeaders = blockHeaders;
_doc.Cache.AnchoredUpTo = anchoredUpTo;
_doc.Cache.AnchoredDownTo = anchoredDownTo;
WalletStore.Save(_doc, _walletPath, _password);
}
catch { /* non-fatal: the next full save will recover */ }
+3 -3
View File
@@ -168,10 +168,10 @@ static async Task<int> Sync(string[] o)
doc.Cache?.NextReceiveIndex ?? 0,
doc.Cache?.NextChangeIndex ?? 0,
net,
doc.Cache?.AnchoredUpTo);
doc.Cache?.AnchoredDownTo);
var result = await sync.SyncOnceAsync();
var (rawHex, verifiedAt, blockHeaders, anchoredUpTo) = sync.ExportCaches(net);
var (rawHex, verifiedAt, blockHeaders, anchoredDownTo) = sync.ExportCaches(net);
doc.Cache = new SyncCache
{
TipHeight = result.TipHeight,
@@ -188,7 +188,7 @@ static async Task<int> Sync(string[] o)
RawTxHex = rawHex,
VerifiedAt = verifiedAt,
BlockHeaders = blockHeaders,
AnchoredUpTo = anchoredUpTo,
AnchoredDownTo = anchoredDownTo,
};
WalletStore.Save(doc, path, CliPassword.Current);
+43 -27
View File
@@ -88,11 +88,13 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
private readonly ConcurrentDictionary<string, int> _verifiedAtHeight = new();
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
// checkpoint height -> highest height already proven to hash-chain back to it.
// Persisted across sessions (see ExportCaches/PreloadCaches): without it, every restart
// re-walks and re-verifies the whole header chain from the checkpoint even though the
// header bytes themselves are cached, which dominates reconnect time on large wallets.
private readonly ConcurrentDictionary<int, int> _anchoredUpTo = new();
// checkpoint height -> lowest height already proven to hash-chain UP to that checkpoint.
// Each anchored header must reach a hardcoded hash above it: SHA-256 preimage resistance
// is what makes it unforgeable (the opposite direction, from a checkpoint up to the header,
// proves nothing when PoW is skipped, since anyone can build such a chain). Persisted across
// sessions (see ExportCaches/PreloadCaches): without it, every restart re-walks and
// re-verifies the header chain even though the header bytes themselves are cached.
private readonly ConcurrentDictionary<int, int> _anchoredDownTo = new();
// Serializes header-range downloads: concurrent AnchorToCheckpointAsync calls (one per tx
// being verified) would otherwise race on overlapping ranges and issue duplicate range
@@ -121,7 +123,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
int knownReceiveIndex,
int knownChangeIndex,
Network network,
Dictionary<int, int>? anchoredUpTo = null)
Dictionary<int, int>? anchoredDownTo = null)
{
foreach (var (txid, hex) in rawTxHex)
{
@@ -130,21 +132,27 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
// filter), so every preloaded entry is safe to mark confirmed too.
_confirmedTxids.TryAdd(txid, 0);
}
foreach (var (txid, height) in verifiedAt)
if (!_verifiedAtHeight.ContainsKey(txid))
_verifiedAtHeight[txid] = height;
// A cache written before the anchoring direction was fixed (ADR 0040) has no
// anchoredDownTo: its "verified" claims rested on a forgeable check, so they are not
// trusted and every proof is re-verified (the cached headers are reused, not refetched).
if (anchoredDownTo is not null)
foreach (var (txid, height) in verifiedAt)
if (!_verifiedAtHeight.ContainsKey(txid))
_verifiedAtHeight[txid] = height;
if (blockHeaders is not null)
foreach (var (height, hex) in blockHeaders)
_headerFetches.TryAdd(height, Task.FromResult(hex));
// Only trust a preloaded anchor up to a height whose header is also cached: if the
// Only trust a preloaded anchor down to a height whose header is also cached: if the
// header cache was cleared/corrupted independently, re-deriving the chain-of-hashes
// check on next use (AnchorToCheckpointAsync re-fetches what's missing) is safer than
// trusting a stale "already validated" claim against headers that may no longer match.
if (anchoredUpTo is not null)
foreach (var (checkpointHeight, upToHeight) in anchoredUpTo)
if (_headerFetches.ContainsKey(upToHeight))
_anchoredUpTo.AddOrUpdate(checkpointHeight, upToHeight,
(_, existing) => Math.Max(existing, upToHeight));
if (anchoredDownTo is not null)
foreach (var (checkpointHeight, downToHeight) in anchoredDownTo)
if (downToHeight <= checkpointHeight
&& _headerFetches.ContainsKey(downToHeight)
&& _headerFetches.ContainsKey(checkpointHeight))
_anchoredDownTo.AddOrUpdate(checkpointHeight, downToHeight,
(_, existing) => Math.Min(existing, downToHeight));
_knownReceiveIndex = knownReceiveIndex;
_knownChangeIndex = knownChangeIndex;
}
@@ -157,7 +165,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
public (Dictionary<string, string> RawTxHex,
Dictionary<string, int> VerifiedAt,
Dictionary<int, string> BlockHeaders,
Dictionary<int, int> AnchoredUpTo)
Dictionary<int, int> AnchoredDownTo)
ExportCaches(Network network)
{
var rawHex = _confirmedTxids.Keys
@@ -172,7 +180,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
headers[height] = task.Result;
return (rawHex, new Dictionary<string, int>(_verifiedAtHeight), headers,
new Dictionary<int, int>(_anchoredUpTo));
new Dictionary<int, int>(_anchoredDownTo));
}
public async Task<SyncResult> SyncOnceAsync(CancellationToken ct = default)
@@ -444,32 +452,40 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
private async Task AnchorToCheckpointAsync(int height, CancellationToken ct)
{
var profile = account.Profile;
Checkpoint? checkpoint = null;
// The checkpoint ABOVE the height (lowest one at or above it): the header must hash-chain
// up to it. Above the last checkpoint there is nothing to anchor to — a documented limit,
// narrowed by adding a recent checkpoint at every release (SECURITY.md).
Checkpoint? upper = null;
foreach (var c in profile.Checkpoints)
if (c.Height <= height && (checkpoint is not { } best || c.Height > best.Height))
checkpoint = c;
if (checkpoint is not { } cp)
if (c.Height >= height && (upper is not { } best || c.Height < best.Height))
upper = c;
if (upper is not { } cp)
return;
if (_anchoredUpTo.TryGetValue(cp.Height, out var anchoredTo) && anchoredTo >= height)
_anchoredDownTo.TryGetValue(cp.Height, out var anchoredFrom);
var alreadyAnchored = _anchoredDownTo.ContainsKey(cp.Height);
if (alreadyAnchored && anchoredFrom <= height)
return;
await FetchHeaderRangeAsync(cp.Height, height, ct);
// Headers [height .. top]: top is the checkpoint itself, or the lowest height already
// proven to reach it (its header is trusted, so only the new segment is walked).
var top = alreadyAnchored ? anchoredFrom : cp.Height;
await FetchHeaderRangeAsync(height, top, ct);
var headers = Enumerable.Range(cp.Height, height - cp.Height + 1)
var headers = Enumerable.Range(height, top - height + 1)
.Select(h => BlockHeaderInfo.Parse(_headerFetches[h].Result))
.ToArray();
if (!headers[0].MatchesCheckpoint(cp))
if (!alreadyAnchored && !headers[^1].MatchesCheckpoint(cp))
throw new SpvVerificationException(
$"Header at checkpoint height {cp.Height} does not match the hardcoded hash: server is not trustworthy.");
for (var i = 1; i < headers.Length; i++)
if (!headers[i].IsValidChild(headers[i - 1].Hash, profile))
throw new SpvVerificationException(
$"Broken header chain at height {cp.Height + i}: server is not trustworthy.");
$"Broken header chain at height {height + i}: server is not trustworthy.");
_anchoredUpTo.AddOrUpdate(cp.Height, height, (_, existing) => Math.Max(existing, height));
_anchoredDownTo.AddOrUpdate(cp.Height, height, (_, existing) => Math.Min(existing, height));
}
/// <summary>
+5 -5
View File
@@ -139,12 +139,12 @@ public sealed class SyncCache
public Dictionary<int, string>? BlockHeaders { get; set; }
/// <summary>
/// Checkpoint height → highest height already proven to hash-chain back to it (§7.3).
/// Avoids re-walking and re-verifying the whole header chain from the checkpoint on
/// every launch: the chain-of-hashes check already done for a height doesn't need
/// redoing once headers themselves are cached.
/// Checkpoint height → lowest height already proven to hash-chain up to it (§7.3, ADR 0040).
/// Avoids re-walking and re-verifying the header chain on every launch once the headers
/// themselves are cached. A cache without this field predates the fix of the anchoring
/// direction: its verified-proof claims are not trusted and get re-verified.
/// </summary>
public Dictionary<int, int>? AnchoredUpTo { get; set; }
public Dictionary<int, int>? AnchoredDownTo { get; set; }
}
/// <summary>Scanned address with its own balance and transaction count (address view).</summary>
@@ -314,13 +314,14 @@ public class WalletSynchronizerTests
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
var chain = ChainedHeaders(100, 105, funding.GetHash());
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var chain = ChainedHeaders(100, 105, funding.GetHash(), roots: new() { [100] = funding.GetHash() });
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
// The checkpoint sits ABOVE the tx (105): the header at 100 must hash-chain up to it.
var checkpointProfile = Profile with
{
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
Checkpoints = [new Checkpoint(105, BlockHeaderInfo.Parse(chain[105]).Hash.ToString(), 0x1d00ffff)],
};
var checkpointAccount = Account(checkpointProfile);
@@ -331,7 +332,7 @@ public class WalletSynchronizerTests
Assert.Equal(1_000_000, result.ConfirmedSats);
// Anchoring runs before the header lookup, so the range call (blockchain.block.headers)
// covering 100..105 back to the checkpoint already includes the tx's own header —
// covering 100..105 up 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"));
@@ -342,21 +343,21 @@ public class WalletSynchronizerTests
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 103);
// Second tx (at height 103) known upfront so the chain can commit to it,
// Second tx (at height 105) known upfront so the chain can commit to it,
// but announced by the server only after the first sync.
var tx2 = Net.CreateTransaction();
tx2.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
tx2.Outputs.Add(Money.Satoshis(500_000), account.GetReceiveAddress(1));
var chain = ChainedHeaders(100, 105, funding.GetHash(),
roots: new() { [103] = tx2.GetHash() });
roots: new() { [103] = funding.GetHash(), [105] = tx2.GetHash() });
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
var checkpointProfile = Profile with
{
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
Checkpoints = [new Checkpoint(105, BlockHeaderInfo.Parse(chain[105]).Hash.ToString(), 0x1d00ffff)],
};
var checkpointAccount = Account(checkpointProfile);
@@ -364,11 +365,11 @@ public class WalletSynchronizerTests
await using var _ = server; await using var __ = client;
var sync = new WalletSynchronizer(checkpointAccount, client);
await sync.SyncOnceAsync(); // walks and memoizes the anchor up to 105
await sync.SyncOnceAsync(); // walks 103..105 up to the checkpoint and memoizes it
// 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).
scenario.Register(tx2, 103, checkpointAccount.GetReceiveAddress(1));
// 105 lies inside the memoized [103, 105]: anchoring must early-return without
// re-walking, so the header/range call counts stay at those of the first walk.
scenario.Register(tx2, 105, checkpointAccount.GetReceiveAddress(1));
var result = await sync.SyncOnceAsync();
Assert.Equal(1_500_000, result.ConfirmedSats);
@@ -376,19 +377,144 @@ public class WalletSynchronizerTests
Assert.Equal(1, server.CallCount("blockchain.block.headers"));
}
[Fact]
public async Task Extending_the_anchor_downwards_only_walks_the_new_segment()
{
var account = Account();
var scenario = new Scenario();
var high = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 104);
var tx2 = Net.CreateTransaction();
tx2.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
tx2.Outputs.Add(Money.Satoshis(500_000), account.GetReceiveAddress(1));
var chain = ChainedHeaders(100, 105, high.GetHash(),
roots: new() { [104] = high.GetHash(), [101] = tx2.GetHash() });
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
var checkpointAccount = Account(Profile with
{
Checkpoints = [new Checkpoint(105, BlockHeaderInfo.Parse(chain[105]).Hash.ToString(), 0x1d00ffff)],
});
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var sync = new WalletSynchronizer(checkpointAccount, client);
await sync.SyncOnceAsync(); // anchors [104, 105]
scenario.Register(tx2, 101, checkpointAccount.GetReceiveAddress(1));
var result = await sync.SyncOnceAsync(); // extends to [101, 105]
Assert.Equal(1_500_000, result.ConfirmedSats);
Assert.Equal(2, server.CallCount("blockchain.block.headers")); // 104..105, then 101..103
Assert.Equal(0, server.CallCount("blockchain.block.header"));
}
[Fact]
public async Task A_server_that_forges_a_consistent_chain_to_the_checkpoint_is_detected()
{
// The attack the anchoring must stop: the server invents the header of the tx's block and
// chains fake headers after it, all with valid prev-hash links. Linking "from a checkpoint
// upward" would accept this; linking "up to a checkpoint" cannot, because the chain does
// not end at the real hardcoded hash.
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var honest = ChainedHeaders(100, 105, uint256.One); // the real chain (tx not in it)
var forged = ChainedHeaders(100, 105, funding.GetHash(),
roots: new() { [100] = funding.GetHash() }); // fabricated, self-consistent
foreach (var (h, hex) in forged) scenario.Headers[h] = hex;
var checkpointAccount = Account(Profile with
{
Checkpoints = [new Checkpoint(105, BlockHeaderInfo.Parse(honest[105]).Hash.ToString(), 0x1d00ffff)],
});
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
() => new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync());
Assert.Contains("checkpoint height 105", ex.Message);
}
[Fact]
public async Task A_tx_above_the_last_checkpoint_has_nothing_to_anchor_to()
{
// Documented limit (SECURITY.md): only the Merkle proof is checked there.
var account = Account();
var scenario = new Scenario();
scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 150);
var checkpointAccount = Account(Profile with
{
Checkpoints = [new Checkpoint(100, uint256.One.ToString(), 0x1d00ffff)],
});
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync();
Assert.Equal(1_000_000, result.ConfirmedSats);
Assert.Equal(0, server.CallCount("blockchain.block.headers"));
}
[Fact]
public async Task A_tx_below_the_first_checkpoint_is_anchored_to_it()
{
// L-01: heights under the first checkpoint used to have no anchor at all.
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 10);
var chain = ChainedHeaders(10, 100, funding.GetHash(), roots: new() { [10] = funding.GetHash() });
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
var checkpointAccount = Account(Profile with
{
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
});
var (server, client) = await StartAsync(scenario);
await using var _ = server; await using var __ = client;
var result = await new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync();
Assert.Equal(1_000_000, result.ConfirmedSats);
Assert.True(server.CallCount("blockchain.block.headers") >= 1);
}
[Fact]
public async Task A_cache_without_the_anchor_state_is_not_trusted_for_verified_proofs()
{
// A cache written before the anchoring direction was fixed has no AnchoredDownTo: its
// "verified" claims are dropped and every proof is checked again.
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;
var first = new WalletSynchronizer(account, client);
var result1 = await first.SyncOnceAsync();
var (rawTx, verifiedAt, headers, _) = first.ExportCaches(Net);
Assert.NotEmpty(verifiedAt);
server.ResetCallCounts();
var second = new WalletSynchronizer(account, client);
second.PreloadCaches(rawTx, verifiedAt, headers,
result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredDownTo: null);
var result2 = await second.SyncOnceAsync();
Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats);
Assert.Equal(1, server.CallCount("blockchain.transaction.get_merkle")); // re-verified
Assert.Equal(0, server.CallCount("blockchain.transaction.get")); // raw tx still reused
}
[Fact]
public async Task Un_checkpoint_con_hash_sbagliato_fa_fallire_la_sync()
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
var chain = ChainedHeaders(100, 105, funding.GetHash());
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var chain = ChainedHeaders(100, 105, funding.GetHash(), roots: new() { [100] = funding.GetHash() });
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
var checkpointProfile = Profile with
{
// Wrong hash at the checkpoint height: the real chain does not match it.
Checkpoints = [new Checkpoint(100, uint256.One.ToString(), 0x1d00ffff)],
Checkpoints = [new Checkpoint(105, uint256.One.ToString(), 0x1d00ffff)],
};
var checkpointAccount = Account(checkpointProfile);
@@ -397,7 +523,7 @@ public class WalletSynchronizerTests
var ex = await Assert.ThrowsAsync<SpvVerificationException>(
() => new WalletSynchronizer(checkpointAccount, client).SyncOnceAsync());
Assert.Contains("checkpoint height 100", ex.Message);
Assert.Contains("checkpoint height 105", ex.Message);
}
[Fact]
@@ -405,15 +531,15 @@ public class WalletSynchronizerTests
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
var chain = ChainedHeaders(100, 105, funding.GetHash());
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var chain = ChainedHeaders(100, 105, funding.GetHash(), roots: new() { [100] = funding.GetHash() });
// Tamper with block 103: it no longer points at block 102's real hash.
chain[103] = Scenario.SingleTxHeaderHex(uint256.One, 103, prevHash: uint256.One);
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
var checkpointProfile = Profile with
{
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
Checkpoints = [new Checkpoint(105, BlockHeaderInfo.Parse(chain[105]).Hash.ToString(), 0x1d00ffff)],
};
var checkpointAccount = Account(checkpointProfile);
@@ -430,13 +556,13 @@ public class WalletSynchronizerTests
{
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
var chain = ChainedHeaders(100, 105, funding.GetHash());
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 100);
var chain = ChainedHeaders(100, 105, funding.GetHash(), roots: new() { [100] = funding.GetHash() });
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
var checkpointAccount = Account(Profile with
{
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
Checkpoints = [new Checkpoint(105, BlockHeaderInfo.Parse(chain[105]).Hash.ToString(), 0x1d00ffff)],
});
var (server, client) = await StartAsync(scenario);
@@ -619,7 +745,7 @@ public class WalletSynchronizerTests
var first = new WalletSynchronizer(account, client);
var result1 = await first.SyncOnceAsync();
var (rawTx, verifiedAt, headers, anchoredUpTo) = first.ExportCaches(Net);
var (rawTx, verifiedAt, headers, anchoredDownTo) = first.ExportCaches(Net);
Assert.Single(rawTx); // the confirmed tx is exported
Assert.Single(verifiedAt); // with its verified height
@@ -629,7 +755,7 @@ public class WalletSynchronizerTests
server.ResetCallCounts();
var second = new WalletSynchronizer(account, client);
second.PreloadCaches(rawTx, verifiedAt, headers,
result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredUpTo);
result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredDownTo);
var result2 = await second.SyncOnceAsync();
Assert.Equal(result1.ConfirmedSats, result2.ConfirmedSats);
@@ -643,23 +769,23 @@ public class WalletSynchronizerTests
{
// Same scenario as "Un_range_gia_ancorato_non_viene_ricamminato_al_sync_successivo", but
// across two separate WalletSynchronizer instances (simulating an app restart) with
// AnchoredUpTo round-tripped through ExportCaches/PreloadCaches like a real save/reload —
// AnchoredDownTo round-tripped through ExportCaches/PreloadCaches like a real save/reload —
// the in-memory-only memoization that test covers wouldn't survive that on its own.
var account = Account();
var scenario = new Scenario();
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 105);
var funding = scenario.Pay(account.GetReceiveAddress(0), 1_000_000, height: 103);
var tx2 = Net.CreateTransaction();
tx2.Inputs.Add(new TxIn(new OutPoint(uint256.One, 1)));
tx2.Outputs.Add(Money.Satoshis(500_000), account.GetReceiveAddress(1));
var chain = ChainedHeaders(100, 105, funding.GetHash(),
roots: new() { [103] = tx2.GetHash() });
roots: new() { [103] = funding.GetHash(), [105] = tx2.GetHash() });
foreach (var (h, hex) in chain) scenario.Headers[h] = hex;
var checkpointProfile = Profile with
{
Checkpoints = [new Checkpoint(100, BlockHeaderInfo.Parse(chain[100]).Hash.ToString(), 0x1d00ffff)],
Checkpoints = [new Checkpoint(105, BlockHeaderInfo.Parse(chain[105]).Hash.ToString(), 0x1d00ffff)],
};
var checkpointAccount = Account(checkpointProfile);
@@ -667,20 +793,20 @@ public class WalletSynchronizerTests
await using var _ = server; await using var __ = client;
var first = new WalletSynchronizer(checkpointAccount, client);
var result1 = await first.SyncOnceAsync(); // walks and memoizes the anchor up to 105
var (rawTx, verifiedAt, headers, anchoredUpTo) = first.ExportCaches(Net);
Assert.NotEmpty(anchoredUpTo);
var result1 = await first.SyncOnceAsync(); // walks 103..105 and memoizes the anchor
var (rawTx, verifiedAt, headers, anchoredDownTo) = first.ExportCaches(Net);
Assert.NotEmpty(anchoredDownTo);
// Fresh synchroniser (new launch) preloaded from the exported caches, then a new tx at
// height 103 — within the already-anchored range — is announced.
scenario.Register(tx2, 103, checkpointAccount.GetReceiveAddress(1));
// height 105 — within the already-anchored range — is announced.
scenario.Register(tx2, 105, checkpointAccount.GetReceiveAddress(1));
server.ResetCallCounts();
var second = new WalletSynchronizer(checkpointAccount, client);
second.PreloadCaches(rawTx, verifiedAt, headers,
result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredUpTo);
result1.NextReceiveIndex, result1.NextChangeIndex, Net, anchoredDownTo);
var result2 = await second.SyncOnceAsync();
// 103 <= the persisted anchor of 105: no header-range re-walk despite this being a
// 105 lies in the persisted anchored range [103, 105]: no header-range re-walk despite this being a
// brand-new synchroniser instance that never anchored anything itself.
Assert.Equal(1_500_000, result2.ConfirmedSats);
Assert.Equal(0, server.CallCount("blockchain.block.header"));
@@ -25,7 +25,7 @@ public class StorageEdgeCaseTests
RawTxHex = new() { ["aa"] = "0102" },
VerifiedAt = new() { ["aa"] = 10 },
BlockHeaders = new() { [10] = "00" },
AnchoredUpTo = new() { [100] = 150 },
AnchoredDownTo = new() { [100] = 150 },
},
};
var path = Path.Combine(Path.GetTempPath(), $"plm-cache-{Guid.NewGuid()}.json");
@@ -43,7 +43,7 @@ public class StorageEdgeCaseTests
Assert.Equal("0102", c.RawTxHex!["aa"]);
Assert.Equal(10, c.VerifiedAt!["aa"]);
Assert.Equal("00", c.BlockHeaders![10]);
Assert.Equal(150, c.AnchoredUpTo![100]);
Assert.Equal(150, c.AnchoredDownTo![100]);
}
finally { File.Delete(path); }
}