feat(spv): cross-check recent headers with a witness server

Blocks above the newest checkpoint cannot be anchored, so the header from
the primary server is now compared with the one from a second server taken
from the hardcoded bootstrap list. A disagreement at 3+ blocks of depth
aborts the sync; a shallower one leaves the proof unverified until the next
sync; with no reachable witness behaviour is unchanged. A bounded
proof-of-work check was evaluated and rejected. See ADR 0042.
This commit is contained in:
davide committed 2026-11-03 15:29:29 +01:00
1 parent 305f22ed0e
commit 9010ebce59
11 files changed
+430 -4

No files matched your search

@@ -0,0 +1,35 @@
# 0042. Cross-check recent headers with a witness server; no bounded-PoW check
- **Status:** Accepted
- **Area:** SPV
- **Recorded:** 2026-11-03
## Context
ADR 0040 anchors every header used for a Merkle proof up to a hardcoded checkpoint, but a block above the newest checkpoint has nothing to anchor to: only the header the primary server supplies and the Merkle proof against it are checked. A hostile server could show a payment that never happened in those recent blocks. ADR 0041 made the limit visible; this ADR reduces it.
## Decision
- **Witness cross-check.** For a block above the newest checkpoint, `WalletSynchronizer` asks a second server (a "witness") for the header at the same height and requires identical bytes. The witnesses come from `WalletSynchronizer.WitnessProvider`, called lazily once, only when such a header is first needed.
- **Witness selection** (`WitnessConnector`): only from the **hardcoded bootstrap list** of the profile, never from the peers a server announces (a hostile server could announce only its own), never the primary server, each verified to be on the chain (ADR 0036). One witness by default (`DefaultMax`). Used by the GUI and the CLI; the GUI closes them with the primary connection.
- **Outcomes.** Agreement: the proof verifies as before. A witness that is unreachable, errors, or does not know the block: skipped. No witness at all: unchanged behaviour (Merkle proof only). Disagreement at a depth of at least `CrossCheckMinDepth` (3) blocks: `SpvVerificationException` ("servers disagree"), the sync aborts. Disagreement at a shallower depth may be a reorganisation in progress: the proof is left **unverified** (not spendable, not cached) and decided again at the next sync.
- Each height is asked once per synchronizer. Heights covered by a checkpoint are not cross-checked.
- The "not anchored" flag of ADR 0041 stays height-based: it is not cleared by a successful cross-check, because that result is not persisted.
## Rejected: bounded proof-of-work
The idea was to require recent headers to meet a target no easier than a bound on the newest checkpoint's difficulty. The data in the shipped checkpoints argue against it: between consecutive checkpoints (20,000 blocks) the target changed by up to 8x easier and 200x harder, so a safe bound would be loose; the network is small (about tens of TH/s at the last checkpoint), so work at a fraction of a block's difficulty is cheap to rent; and a bound that is too tight would make the wallet reject honest servers whenever hash rate drops. It would add cost and a false-rejection risk for little protection. Not implemented.
## Consequences
- A single lying server can no longer fabricate a recent payment on its own: it would have to control the witness too. A witness run by the same operator as the primary, or an attacker positioned in front of both, defeats it; this is a hardening, not a proof.
- One extra connection (TLS, pinned) is opened when a recent block needs checking; none for wallets whose history is all below the newest checkpoint.
- A transaction in a block 1-2 deep on which the servers briefly disagree shows as "verifying" until the next sync.
- A fresh checkpoint in every release still shrinks the window the cross-check has to cover.
## References
- `src/Core/Spv/WalletSynchronizer.cs`
- `src/Core/Spv/WitnessConnector.cs`
- `tests/PalladiumWallet.Tests/Spv/WalletSynchronizerTests.cs`
- Related: 0036, 0040, 0041
+1
View File
@@ -45,6 +45,7 @@ One ADR per feature or significant design decision. Format: `template.md`. Workf
| 0039 | [Server fee estimates, relay floor and a high-fee warning](0039-server-fee-estimates-and-relay-floor.md) | Wallet | Accepted |
| 0040 | [Anchor headers up to a checkpoint above them](0040-anchor-headers-up-to-a-checkpoint.md) | SPV | Accepted |
| 0041 | [Flag transactions above the newest checkpoint as not anchored](0041-flag-transactions-above-the-newest-checkpoint.md) | SPV | Accepted |
| 0042 | [Cross-check recent headers with a witness server; no bounded-PoW check](0042-cross-check-recent-headers-with-a-witness-server.md) | SPV | Accepted |
ADRs `0002` onwards were written retroactively on 2026-11-03 from the code, comments and changelog.
If one misstates the original reasoning, fix it in a pull request.