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:
@@ -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 = [],
|
||||
};
|
||||
|
||||
|
||||
@@ -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).
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user