diff --git a/CHANGELOG.md b/CHANGELOG.md index 9858703..952e245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/SECURITY.md b/SECURITY.md index e50d253..9b23e6b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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: diff --git a/USERGUIDE.md b/USERGUIDE.md index 177d4b6..8216dd5 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -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. | diff --git a/docs/adr/0006-spv-checkpoint-anchoring-without-pow.md b/docs/adr/0006-spv-checkpoint-anchoring-without-pow.md index 1fd41c5..e3e84b2 100644 --- a/docs/adr/0006-spv-checkpoint-anchoring-without-pow.md +++ b/docs/adr/0006-spv-checkpoint-anchoring-without-pow.md @@ -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) diff --git a/docs/adr/0040-anchor-headers-up-to-a-checkpoint.md b/docs/adr/0040-anchor-headers-up-to-a-checkpoint.md new file mode 100644 index 0000000..070f962 --- /dev/null +++ b/docs/adr/0040-anchor-headers-up-to-a-checkpoint.md @@ -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 diff --git a/docs/adr/README.md b/docs/adr/README.md index 44563b7..e192b9b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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. diff --git a/src/App/ViewModels/MainWindowViewModel.Sync.cs b/src/App/ViewModels/MainWindowViewModel.Sync.cs index dedfc69..a8abbd5 100644 --- a/src/App/ViewModels/MainWindowViewModel.Sync.cs +++ b/src/App/ViewModels/MainWindowViewModel.Sync.cs @@ -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 */ } diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs index a0aed57..483d71e 100644 --- a/src/Cli/Program.cs +++ b/src/Cli/Program.cs @@ -168,10 +168,10 @@ static async Task 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 Sync(string[] o) RawTxHex = rawHex, VerifiedAt = verifiedAt, BlockHeaders = blockHeaders, - AnchoredUpTo = anchoredUpTo, + AnchoredDownTo = anchoredDownTo, }; WalletStore.Save(doc, path, CliPassword.Current); diff --git a/src/Core/Spv/WalletSynchronizer.cs b/src/Core/Spv/WalletSynchronizer.cs index 7e3886e..0f64191 100644 --- a/src/Core/Spv/WalletSynchronizer.cs +++ b/src/Core/Spv/WalletSynchronizer.cs @@ -88,11 +88,13 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl private readonly ConcurrentDictionary _verifiedAtHeight = new(); private readonly ConcurrentDictionary> _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 _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 _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? anchoredUpTo = null) + Dictionary? 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 RawTxHex, Dictionary VerifiedAt, Dictionary BlockHeaders, - Dictionary AnchoredUpTo) + Dictionary 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(_verifiedAtHeight), headers, - new Dictionary(_anchoredUpTo)); + new Dictionary(_anchoredDownTo)); } public async Task 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)); } /// diff --git a/src/Core/Storage/WalletDocument.cs b/src/Core/Storage/WalletDocument.cs index 62939b2..fcc4345 100644 --- a/src/Core/Storage/WalletDocument.cs +++ b/src/Core/Storage/WalletDocument.cs @@ -139,12 +139,12 @@ public sealed class SyncCache public Dictionary? BlockHeaders { get; set; } /// - /// 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. /// - public Dictionary? AnchoredUpTo { get; set; } + public Dictionary? AnchoredDownTo { get; set; } } /// Scanned address with its own balance and transaction count (address view). diff --git a/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs b/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs index 1db3dd1..1e6d414 100644 --- a/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs +++ b/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs @@ -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( + () => 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( () => 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")); diff --git a/tests/PalladiumWallet.Tests/Storage/StorageEdgeCaseTests.cs b/tests/PalladiumWallet.Tests/Storage/StorageEdgeCaseTests.cs index 9266ae7..0c2619d 100644 --- a/tests/PalladiumWallet.Tests/Storage/StorageEdgeCaseTests.cs +++ b/tests/PalladiumWallet.Tests/Storage/StorageEdgeCaseTests.cs @@ -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); } }