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:
+1
-1
@@ -21,7 +21,7 @@ It does **not** protect against:
|
|||||||
|
|
||||||
This wallet is an SPV client, not a full node. It validates:
|
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`)
|
- Transaction inclusion in a confirmed block (Merkle branch proof, mandatory for every confirmed transaction — see `Core/Spv/MerkleProof.cs`)
|
||||||
|
|
||||||
It does **not** validate:
|
It does **not** validate:
|
||||||
|
|||||||
+7
-4
@@ -567,9 +567,12 @@ or servers instead of resetting.
|
|||||||
Each sync: gets the chain tip → scans your receive and change address chains (gap limit
|
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
|
20) → downloads your transactions → **verifies a Merkle proof for every confirmed
|
||||||
transaction** against its block header → rebuilds your UTXO set and balances locally.
|
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
|
On mainnet, each block header used for a proof is also chained back (via its previous-block
|
||||||
after a restart. If a proof does not check out, the sync **aborts** with an explicit
|
hash) to the nearest built-in checkpoint, so a server cannot substitute a fabricated header
|
||||||
"server is not trustworthy" error rather than showing you unverified data; switch servers.
|
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
|
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
|
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)). |
|
| *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). |
|
| *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. |
|
| *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. |
|
| *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. |
|
| 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. |
|
| *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. |
|
||||||
|
|||||||
@@ -47,8 +47,38 @@ public static class ChainProfiles
|
|||||||
new ServerEndpoint("66.94.115.80", 50001, 50002),
|
new ServerEndpoint("66.94.115.80", 50001, 50002),
|
||||||
new ServerEndpoint("89.117.149.130", 50001, 50002),
|
new ServerEndpoint("89.117.149.130", 50001, 50002),
|
||||||
],
|
],
|
||||||
// TODO: populate with the chain's real [hash, bits] (§7.3) before release.
|
// Real mainnet [height, hash, bits], pulled from a fully-synced palladiumd via
|
||||||
Checkpoints = [],
|
// 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
|
public static ChainProfile Testnet { get; } = Mainnet with
|
||||||
@@ -78,6 +108,9 @@ public static class ChainProfiles
|
|||||||
[ScriptKind.Taproot] = new(0x04358394, 0x043587cf), // tprv / tpub (BIP32 standard)
|
[ScriptKind.Taproot] = new(0x04358394, 0x043587cf), // tprv / tpub (BIP32 standard)
|
||||||
},
|
},
|
||||||
BootstrapServers = [],
|
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 = [],
|
Checkpoints = [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,6 +123,7 @@ public static class ChainProfiles
|
|||||||
// TODO: verify against the node's chainparams.cpp (Bitcoin regtest genesis assumed).
|
// TODO: verify against the node's chainparams.cpp (Bitcoin regtest genesis assumed).
|
||||||
GenesisHash = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206",
|
GenesisHash = "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206",
|
||||||
NodeP2pPort = 28444,
|
NodeP2pPort = 28444,
|
||||||
|
// Regtest is regenerated locally on demand: hardcoded checkpoints make no sense here.
|
||||||
Checkpoints = [],
|
Checkpoints = [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ public sealed class WalletSynchronizer(IWalletAccount account, ElectrumClient cl
|
|||||||
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
private readonly Dictionary<string, int> _verifiedAtHeight = [];
|
||||||
private readonly ConcurrentDictionary<int, Task<string>> _headerFetches = new();
|
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
|
// Indices known from the previous sync: used by ScanChainAsync for incremental
|
||||||
// discovery — already-used addresses are fetched in a single burst instead of
|
// discovery — already-used addresses are fetched in a single burst instead of
|
||||||
// sequential batches, reducing round-trips from O(used/gapLimit) to O(1).
|
// 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));
|
h => client.GetBlockHeaderAsync(h, ct));
|
||||||
var proof = await proofTask;
|
var proof = await proofTask;
|
||||||
var header = BlockHeaderInfo.Parse(await headerTask);
|
var header = BlockHeaderInfo.Parse(await headerTask);
|
||||||
|
await AnchorToCheckpointAsync(height, ct);
|
||||||
if (!MerkleProof.Verify(
|
if (!MerkleProof.Verify(
|
||||||
uint256.Parse(txid), proof.Pos,
|
uint256.Parse(txid), proof.Pos,
|
||||||
proof.Merkle.Select(uint256.Parse), header.MerkleRoot))
|
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>
|
/// <summary>
|
||||||
/// Scans one chain (receiving or change).
|
/// Scans one chain (receiving or change).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ public class WalletSynchronizerTests
|
|||||||
private static readonly ChainProfile Profile = ChainProfiles.Regtest;
|
private static readonly ChainProfile Profile = ChainProfiles.Regtest;
|
||||||
private static readonly Network Net = PalladiumNetworks.Regtest;
|
private static readonly Network Net = PalladiumNetworks.Regtest;
|
||||||
|
|
||||||
private static HdAccount Account()
|
private static HdAccount Account(ChainProfile? profile = null)
|
||||||
{
|
{
|
||||||
Assert.True(Bip39.TryParse(
|
Assert.True(Bip39.TryParse(
|
||||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||||
out var mnemonic));
|
out var mnemonic));
|
||||||
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, Profile);
|
return HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, profile ?? Profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -71,10 +71,11 @@ public class WalletSynchronizerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>80-byte header of a block whose only transaction is <paramref name="txid"/>.</summary>
|
/// <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();
|
var header = Net.Consensus.ConsensusFactory.CreateBlockHeader();
|
||||||
header.HashPrevBlock = uint256.Zero;
|
header.HashPrevBlock = prevHash ?? uint256.Zero;
|
||||||
header.HashMerkleRoot = merkleRoot ?? txid;
|
header.HashMerkleRoot = merkleRoot ?? txid;
|
||||||
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
|
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + height);
|
||||||
header.Bits = new Target(0x1d00ffffu);
|
header.Bits = new Target(0x1d00ffffu);
|
||||||
@@ -273,6 +274,119 @@ public class WalletSynchronizerTests
|
|||||||
Assert.Contains(funding.GetHash().ToString(), ex.Message);
|
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 ----
|
// ---- resilienza ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user