From e349a80ddcdf75dfb22e0ba163d533b998878df3 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 7 Jul 2026 15:12:09 +0200 Subject: [PATCH] feat(spv): anchor header trust to hardcoded mainnet checkpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChainProfiles.Mainnet.Checkpoints was an empty array with a "populate before release" TODO, and even BlockHeaderInfo.MatchesCheckpoint()/ IsValidChild() — the methods meant to enforce it — were never called anywhere in WalletSynchronizer. So on this LWMA chain, where PoW can't be recomputed client-side (SkipPowValidation), a malicious or eclipsing server could hand back any internally-consistent 80-byte header for a Merkle proof: nothing tied it to the real Palladium chain. Populated Mainnet.Checkpoints with 24 real [height, hash, bits] checkpoints (every 20,000 blocks + one near tip), pulled via RPC from a fully-synced palladiumd. Testnet/Regtest are left empty (no node available to verify against, and WalletSynchronizer already treats a missing checkpoint as a no-op, so this is safe, just unanchored). Added WalletSynchronizer.AnchorToCheckpointAsync: for every header used in a Merkle proof, downloads the intervening headers back to the nearest checkpoint at or below that height and verifies an unbroken prev-hash chain terminating in the checkpoint's exact hash, finally wiring up the previously-dead MatchesCheckpoint/IsValidChild. Verified ranges are memoized in-memory per sync session to avoid re-walking. Updated SECURITY.md and USERGUIDE.md to describe what is actually enforced now (previously the guide deliberately avoided promising checkpoint anchoring, since the array was empty). --- SECURITY.md | 2 +- USERGUIDE.md | 11 +- src/Core/Chain/ChainProfiles.cs | 38 +++++- src/Core/Spv/WalletSynchronizer.cs | 44 +++++++ .../Spv/WalletSynchronizerTests.cs | 122 +++++++++++++++++- 5 files changed, 206 insertions(+), 11 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 07e0887..a170d9a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,7 +21,7 @@ It does **not** protect against: This wallet is an SPV client, not a full node. It validates: -- Block headers (proof of work checked up to the last checkpoint; `SkipPowValidation` is enabled because LWMA difficulty cannot be recomputed client-side — trust is anchored to hardcoded checkpoints in `Core/Chain/ChainProfiles.cs`) +- 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 - 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 82709c5..f114e83 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -567,9 +567,12 @@ 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. -Verified data is cached in the wallet file, so later syncs only fetch what is new — even -after a restart. If a proof does not check out, the sync **aborts** with an explicit -"server is not trustworthy" error rather than showing you unverified data; switch servers. +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 +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. If the server is overloaded (busy responses), the wallet retries automatically up to 8 times with increasing back-off — a large wallet's first sync may take a little while, but it @@ -806,7 +809,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: the server served data it cannot prove. | 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 back to a known checkpoint. | Switch to another server. Your local data is intact; the wallet refused the bad data. | | *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. | | *Invalid mnemonic (wrong words or checksum)* | A word is misspelled, not on the BIP39 list, or the checksum fails. | Compare against your paper backup, word by word. | diff --git a/src/Core/Chain/ChainProfiles.cs b/src/Core/Chain/ChainProfiles.cs index 1d0fda1..dcfceb7 100644 --- a/src/Core/Chain/ChainProfiles.cs +++ b/src/Core/Chain/ChainProfiles.cs @@ -47,8 +47,38 @@ public static class ChainProfiles new ServerEndpoint("66.94.115.80", 50001, 50002), new ServerEndpoint("89.117.149.130", 50001, 50002), ], - // TODO: populate with the chain's real [hash, bits] (§7.3) before release. - Checkpoints = [], + // Real mainnet [height, hash, bits], pulled from a fully-synced palladiumd via + // RPC (getblockhash/getblockheader), spaced every 20,000 blocks (~660h) plus one + // recent block. Anchors WalletSynchronizer's header-chain verification (§7.3): + // bounds how far back a forged header chain must be walked to be caught, since + // this LWMA chain cannot be PoW-validated locally (SkipPowValidation). + Checkpoints = + [ + new Checkpoint(20000, "00000000000018ffaa9a332cfb418b5c8c3f988cf26598e378bbea9e93d26f74", 0x1b00ffff), + new Checkpoint(40000, "000000000000011ad2ae53d9647a2130d2b1c41b18d200455211f1fef2a4ffb7", 0x1a013d28), + new Checkpoint(60000, "000000000000034288c737d62011855fb598cd5c6ebb5c46c08acdec17620c8b", 0x1a0390b5), + new Checkpoint(80000, "00000000000003662534639c7eb1dd7166efd85c308be19b367467c015279361", 0x1a0577b1), + new Checkpoint(100000, "0000000000000850eba93bbc491f085e2c79c0c30c497292858c72e90cae69a5", 0x1a2c39e4), + new Checkpoint(120000, "0000000000004c421e06c84f08a947d994cb801b8ac7cade12d616209d851d43", 0x1a510836), + new Checkpoint(140000, "00000000000075b1a095a5969a2ca729b646ab0e2b9a9bd72aa603b3b889c398", 0x1b00a257), + new Checkpoint(160000, "000000000000028c8ba89e695f80fc78491bcf7e583fc7cd868e0a9c2973dfbe", 0x1a0ed8bb), + new Checkpoint(180000, "00000000000003632ffdcf60ce3892f44613dbbfe761b14522e91a1e650c092f", 0x1a064544), + new Checkpoint(200000, "000000000000221a9e16556453fc86308b260d95d80c14bafaf053a09374e7eb", 0x1a22c142), + new Checkpoint(220000, "0000000000001f63d259df5b9b20182dbaea92f2858fe836b895b2a33430c6dd", 0x1a3311af), + new Checkpoint(240000, "0000000000004c8a80484a1d6ab8a08460ac688445ccafe5cbac8b11bc471f11", 0x1a764de8), + new Checkpoint(260000, "000000000000ab1b71140485359633ef991588613f520052ce87acb70a36b4de", 0x1b022691), + new Checkpoint(280000, "00000000000678b07eda63cf01b099737ce832470d0e71769d157190f4d9ac9b", 0x1b118047), + new Checkpoint(300000, "0000000000013acdf07a4fb988bbe9824c36eb421478a71c8196cf524dcba143", 0x1b01ddc1), + new Checkpoint(320000, "000000000000079f2fb9866f1bb452ee5f47a35e0d494c6bc90331f582b07991", 0x1a0e8592), + new Checkpoint(340000, "000000000000000e45f7fbcff239da7965e1bd58aea3a10aef2bc8afbca822be", 0x1a0328e2), + new Checkpoint(360000, "000000000000016d60397423447eb42f8b4ba693fe16f1e64e9fdf58c94ca2a6", 0x1a020861), + new Checkpoint(380000, "000000000000004ba0d45a0462501a12251947d27e52ec810edd69007b7acf90", 0x1a01568d), + new Checkpoint(400000, "0000000000000010ac708514e8b837703233161099bf55400433cef32311f495", 0x1a01ce70), + new Checkpoint(420000, "00000000000000351392487709e637bc7a9b0b2296a0e443f54e2af5b3f00e16", 0x1a01197e), + new Checkpoint(440000, "00000000000001b09d7da81403a9b383a734305a8783cb3a0dbe009edea26a95", 0x1a0216c4), + new Checkpoint(460000, "00000000000000ecc7413f638bfe7be80a36bacab858ce9a814f194d9df526d5", 0x1a07dd8f), + new Checkpoint(468800, "000000000000052c61652eed72b441d8c1f1926710a8d691d101be4961dba105", 0x1a1838ee), + ], }; public static ChainProfile Testnet { get; } = Mainnet with @@ -78,6 +108,9 @@ public static class ChainProfiles [ScriptKind.Taproot] = new(0x04358394, 0x043587cf), // tprv / tpub (BIP32 standard) }, BootstrapServers = [], + // TODO: populate from a synced testnet palladiumd (§7.3) — left empty because + // WalletSynchronizer already treats "no checkpoint at or below this height" as + // a no-op, so an empty array is safe, just unanchored. Checkpoints = [], }; @@ -90,6 +123,7 @@ public static class ChainProfiles // TODO: verify against the node's chainparams.cpp (Bitcoin regtest genesis assumed). GenesisHash = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206", NodeP2pPort = 28444, + // Regtest is regenerated locally on demand: hardcoded checkpoints make no sense here. Checkpoints = [], }; diff --git a/src/Core/Spv/WalletSynchronizer.cs b/src/Core/Spv/WalletSynchronizer.cs index 2abeea0..48ab348 100644 --- a/src/Core/Spv/WalletSynchronizer.cs +++ b/src/Core/Spv/WalletSynchronizer.cs @@ -50,6 +50,10 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl private readonly Dictionary _verifiedAtHeight = []; private readonly ConcurrentDictionary> _headerFetches = new(); + // checkpoint height -> highest height already proven to hash-chain back to it + // (in-memory only: cheap to recompute from _headerFetches, no need to persist). + private readonly ConcurrentDictionary _anchoredUpTo = new(); + // Indices known from the previous sync: used by ScanChainAsync for incremental // discovery — already-used addresses are fetched in a single burst instead of // sequential batches, reducing round-trips from O(used/gapLimit) to O(1). @@ -189,6 +193,7 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl 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)) @@ -297,6 +302,45 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl }; } + /// + /// Anchors a header at to the nearest hardcoded checkpoint at + /// or below it (§7.3): downloads every intervening header and verifies an unbroken + /// prev-hash chain from the checkpoint's known-good hash up to this height. Without this, + /// the Merkle proof above only proves a transaction belongs to *some* header the server + /// handed over — on this LWMA chain the wallet cannot recompute PoW to catch a forged one, + /// so the checkpoint is the only fixed point of truth. A no-op when the network profile has + /// no checkpoint at or below (e.g. testnet/regtest today, or + /// mainnet heights below the first checkpoint). + /// + private async Task AnchorToCheckpointAsync(int height, CancellationToken ct) + { + var profile = account.Profile; + Checkpoint? checkpoint = 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) + return; + + if (_anchoredUpTo.TryGetValue(cp.Height, out var anchoredTo) && anchoredTo >= height) + return; + + var headers = await Task.WhenAll(Enumerable.Range(cp.Height, height - cp.Height + 1) + .Select(async h => BlockHeaderInfo.Parse( + await _headerFetches.GetOrAdd(h, hh => client.GetBlockHeaderAsync(hh, ct))))); + + if (!headers[0].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."); + + _anchoredUpTo.AddOrUpdate(cp.Height, height, (_, existing) => Math.Max(existing, height)); + } + /// /// Scans one chain (receiving or change). /// diff --git a/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs b/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs index 03443a8..535ad7f 100644 --- a/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs +++ b/tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs @@ -21,12 +21,12 @@ public class WalletSynchronizerTests private static readonly ChainProfile Profile = ChainProfiles.Regtest; private static readonly Network Net = PalladiumNetworks.Regtest; - private static HdAccount Account() + private static HdAccount Account(ChainProfile? profile = null) { Assert.True(Bip39.TryParse( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", out var mnemonic)); - return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, Profile); + return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, profile ?? Profile); } /// @@ -71,10 +71,11 @@ public class WalletSynchronizerTests } /// 80-byte header of a block whose only transaction is . - public static string SingleTxHeaderHex(uint256 txid, int height, uint256? merkleRoot = null) + public static string SingleTxHeaderHex(uint256 txid, int height, uint256? merkleRoot = null, + uint256? prevHash = null) { var header = Net.Consensus.ConsensusFactory.CreateBlockHeader(); - header.HashPrevBlock = uint256.Zero; + header.HashPrevBlock = prevHash ?? uint256.Zero; header.HashMerkleRoot = merkleRoot ?? txid; header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height); header.Bits = new Target(0x1d00ffffu); @@ -273,6 +274,119 @@ public class WalletSynchronizerTests Assert.Contains(funding.GetHash().ToString(), ex.Message); } + // ---- checkpoint anchoring ---- + + /// + /// Builds a chain of single-tx headers from to + /// (inclusive), each linked to the previous via + /// HashPrevBlock, and registers them on the scenario. The last height carries + /// as its Merkle root (single-tx block). + /// + private static Dictionary ChainedHeaders(int fromHeight, int toHeight, uint256 txid) + { + var headers = new Dictionary(); + uint256? prevHash = null; + for (var h = fromHeight; h <= toHeight; h++) + { + var hex = Scenario.SingleTxHeaderHex(h == toHeight ? txid : uint256.One, h, + merkleRoot: h == toHeight ? txid : null, prevHash: prevHash); + headers[h] = hex; + prevHash = BlockHeaderInfo.Parse(hex).Hash; + } + return headers; + } + + [Fact] + public async Task Una_tx_anchorata_a_un_checkpoint_valido_verifica_la_catena_di_header() + { + 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()); + 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)], + }; + var checkpointAccount = Account(checkpointProfile); + + 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); + // 100..105 inclusive = 6 headers fetched to walk the chain back to the checkpoint. + Assert.Equal(6, server.CallCount("blockchain.block.header")); + } + + [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()); + 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)], + }; + var checkpointAccount = Account(checkpointProfile); + + 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 100", ex.Message); + } + + [Fact] + public async Task Una_catena_di_header_spezzata_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()); + // 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)], + }; + var checkpointAccount = Account(checkpointProfile); + + 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("Broken header chain at height 103", ex.Message); + } + + [Fact] + public async Task Senza_checkpoint_a_copertura_dell_altezza_l_anchoring_e_un_no_op() + { + // Regtest profile (Profile) has no checkpoints at all: same behaviour as before + // this feature existed — only the Merkle proof is checked. + 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 result = await new WalletSynchronizer(account, client).SyncOnceAsync(); + + Assert.Equal(1_000_000, result.ConfirmedSats); + Assert.Equal(1, server.CallCount("blockchain.block.header")); + } + // ---- resilienza ---- [Fact]