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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user