feat(spv): anchor header trust to hardcoded mainnet checkpoints

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).
This commit is contained in:
2026-07-07 15:12:09 +02:00
parent 69be42aba0
commit e349a80ddc
5 changed files with 206 additions and 11 deletions
+1 -1
View File
@@ -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:
+7 -4
View File
@@ -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. |
+36 -2
View File
@@ -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 = [],
};
+44
View File
@@ -50,6 +50,10 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
private readonly Dictionary<string, int> _verifiedAtHeight = [];
private readonly ConcurrentDictionary<int, Task<string>> _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<int, int> _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
};
}
/// <summary>
/// Anchors a header at <paramref name="height"/> 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 <paramref name="height"/> (e.g. testnet/regtest today, or
/// mainnet heights below the first checkpoint).
/// </summary>
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));
}
/// <summary>
/// Scans one chain (receiving or change).
///
@@ -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);
}
/// <summary>
@@ -71,10 +71,11 @@ public class WalletSynchronizerTests
}
/// <summary>80-byte header of a block whose only transaction is <paramref name="txid"/>.</summary>
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 ----
/// <summary>
/// Builds a chain of single-tx headers from <paramref name="fromHeight"/> to
/// <paramref name="toHeight"/> (inclusive), each linked to the previous via
/// HashPrevBlock, and registers them on the scenario. The last height carries
/// <paramref name="txid"/> as its Merkle root (single-tx block).
/// </summary>
private static Dictionary<int, string> ChainedHeaders(int fromHeight, int toHeight, uint256 txid)
{
var headers = new Dictionary<int, string>();
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<SpvVerificationException>(
() => 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<SpvVerificationException>(
() => 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]