dash-spv's quorums become Platform's trust root (dashpay/platform#3417).
Since #1168 a ChainLock needs only a DIP-6 `Verified` signer, but DIP-6
checks a commitment only against members from the list at its work block,
and nothing tied that list to the chain the network locked. #1200 binds a
list to its block's coinbase, and DGW prices headers, but proof of work is
not finality: a miner with real hashrate can serve a short branch whose
coinbases commit to lists, and ChainLock quorums, of its own, which then
verify and sign ChainLocks for that branch.
The masternode engine now holds a ChainLock anchor: the highest block it
holds as final. Its root is a compiled-in checkpoint past V20 on mainnet
and testnet, or, on regtest and devnet, which have no checkpoints, the
first ChainLock a DIP-6 verified quorum signs. A list at or below the
anchor, on the header chain, is anchored. Trust is checked when read,
never stored:
- A ChainLock is accepted only when its DIP-8 signing quorum is anchored
as active at `height - 8`, and then raises the anchor.
- The anchored entry's key verifies it, never one from a list above the
anchor.
- Selection reads a list holding exactly the quorums active at the
signing height, or both lists around it when a quorum was mined
between them.
- An anchored list below vouches only for those of its quorums the
mining windows in between cannot have retired. Equal quorum sets count
only between anchored lists.
- A ChainLock that waits for an anchored signer stays pending instead of
counting as invalid, and is retried as the anchor moves. One is rejected
only when an anchored list holding exactly the quorums active at its
signing height lacks its signer.
The anchor walks up from an old checkpoint, or across a long gap, through
the ChainLocks block coinbases carry (DIP-29):
- The masternode manager fetches the list a few ChainLock mining windows
above the anchor, then the coinbases of the blocks just above it, until
an anchored quorum signed one of them.
- A coinbase counts only if the stored header of its block proves it, as
for every list the engine builds (#1200). A proven coinbase is the
block's own, so whatever its ChainLock the walk moves on.
- Requests go through the broker, one at a time, declared again on every
tick and released once the walk stops waiting for them. A request is
given up after 60 s, and the walk backs off: 30 s, doubling, at most
10 min.
- An answer the engine refuses keeps the request wanted, and its peer is
not heard again for it, so the broker asks another peer; a refusal that
blames the peer (#1258) also bans it. Once two peers, or every peer
there is, sent answers refused without blame, the walk backs off
rather than have peer after peer kicked. A gap of ours, such as a
pruned base, plans the request again once.
- The walk is not a pending request of the masternode pipeline, so tip
updates and QRInfo go on while it runs. Once synced it stays below the
newest list, and its lists alone do not end an initial sync.
- It runs before an initial sync's QRInfo, which waits for it for at most
30 s, and whenever a synced client's anchor falls behind.
- Its ChainLocks are announced as validated `ChainLockReceived` events,
so the ChainLock manager persists them.
- Pruning keeps the newest anchored list and the lists up to a ChainLock
quorum's lifetime above it, which the walk builds on.
The anchor is persisted under the new `chainlock_anchor` key with its
root, and one whose root this network does not start trust from is
ignored. A best ChainLock persisted before this change is no longer
trusted, and only keeps forks from going below it while there is no
anchor. Since headers flush later than the anchor is persisted:
- an anchor is used only while the stored header at its height is its
block;
- header sync refuses a header that conflicts with it;
- forks below it are refused, and `handle_fork` refuses to truncate below
it, once the stored chain holds its block. Until then the stored tip
may lie on a branch a crash left below it, which the chain through the
anchored block must be free to replace.
Every embedded checkpoint trust may start at is pinned to the hash public
block explorers report for its height.
Residual: a quorum whose base block is below the stored chain, as the
checkpoint's are, cannot be aged against its like. It is vouched for only
while fewer than `active` mining windows passed above the stored chain's
start.
A new ChainLock quorum is trusted once a ChainLock an already anchored
quorum signs covers the block that mined it. On regtest, with two active
ChainLock quorums, about half the ChainLocks right after a DKG cycle come
from the new one, so the `dashd_masternode` tests mine until the client's
ChainLock height reaches what they need.
Test would have caught this in CI:
`a_chain_lock_signed_by_a_quorum_minted_above_the_anchor_is_not_accepted`
fails on the parent, where the forged ChainLock is accepted
(`validated: true`), and passes here.
BREAKING CHANGE: ChainLocks need an anchored signer.
`ChainLockReceived { validated: true }` is also emitted for the anchor's
walk, for blocks well below the tip. The best ChainLock moves to the
`chainlock_anchor` metadata key, so upgraded clients re-anchor from their
checkpoint.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Why
dashpay/platform#3417 uses dash-spv's
Verifiedquorum keys as the trust root for Platform proofs.Verifiedonly meant that DIP-6 checked a commitment against members taken from the list at its work block. Nothing tied that list to the chain the network locked:Verifiedsigner, so a quorum in such a list can also sign ChainLocks.last_commitment_per_index, which no coinbase commits to.What
The masternode engine holds a ChainLock anchor: the highest block it treats as final.
Verifiedquorum.MasternodeListEngine::accept_chain_lock)h - 8, with a signer that is anchored as active at the signing height. The anchored bound retires the quorums the mining windows in between may have retired. The anchored entry's key checks the signature. This replaces #1168'sVerifiedsigner check. An accepted ChainLock raises the anchor. A ChainLock is rejected only when an anchored list holding exactly the quorums active at its signing height lacks the signer. Otherwise it waits.get_quorum_at_height(Platform)Skipped(MissingChainLock(list height, list block)), which Platform already treats as transient. No Platform change is needed.latest_anchored_masternode_list()latest_masternode_list()today.Catch-up from a checkpoint or after a long gap. ChainLock quorums live about 2 days on mainnet and 1 day on testnet, and peers don't serve historical CLSIGs. The walk uses the ChainLock every block's coinbase carries instead (DIP-29: CbTx v3
bestCLSignature):How the walk behaves:
ChainLockReceived { validated: true }, and the ChainLock manager persists it.Persistence:
chainlock_anchormetadata key, together with its root. On restore it is accepted only if that root is one this network starts trust from.best_chainlockis no longer trusted. It only acts as a fork floor while no anchor exists.handle_forkrefuses to truncate below it. Until then the stored tip may sit on a branch a crash left below the anchor, so the chain through the anchored block must be free to replace that branch.What anchoring guarantees
Verifiedcome only from lists at or below a ChainLock that an anchored quorum signed. That chain of trust is rooted at a checkpoint on mainnet and testnet. With fix(dash-spv)!: verify MnListDiff against its block's coinbase before applying #1200, an anchored list's contents are what its block's coinbase commits to.What #1200 lacks compared with #1206 (closed), and why anchoring doesn't need it
Each of these fails closed on dev, by refusing the list. None is a forgery path:
CoinbaseCommitmentMismatch), which stalls masternode sync, the walk included, until the crate learns the type. This is worth a PR of its own.Overlap with #1293
#1293 takes rotated quarter ChainLocks from the work blocks' coinbases. It changes DIP-24 member selection, not which quorums are trusted, so the two don't conflict in meaning.
sml_engine.rs(here:mod anchor, two engine fields) and the doc comments inllmq_entry_verification.rs. Whichever lands second rebases trivially.Skipped(MissingChainLock). In both PRs it means a ChainLock is missing.Behaviour to expect
mine_until_chain_locked_at_least).Not covered (follow-ups)
activemining windows of the stored chain's start.Verified, which is a denial of service.latest_anchored_masternode_list();is_readyalso compare the ChainLockbest_validated_heightwith the tip.ClientConfig::chainlock_anchor) for a network-wide ChainLock outage longer than a quorum's lifetime. Without it, fresh clients get no trust until a newer checkpoint ships.Breaking
get_quorum_at_heightreports quorums above the anchor asSkipped(MissingChainLock(..)).ChainLockReceived { validated: true }is also emitted during the catch-up walk, for blocks well below the tip.chainlock_anchormetadata key, so upgraded clients re-anchor from their checkpoint.dashchanges only a doc comment: it reusesLLMQEntryVerificationSkipStatus::MissingChainLock.Commits
feat(dash-spv)!: accept ChainLocks only from anchored quorums: the anchor, ChainLock acceptance, the walk, persistence and the fork floor.feat(dash-spv)!: report quorums above the anchor as not verified:get_quorum_at_heightandlatest_anchored_masternode_list.feat(dash-spv): verify IS locks only against anchored commitmentsTests
a_chain_lock_signed_by_a_quorum_minted_above_the_anchor_is_not_accepteda_quorum_no_verified_chain_lock_covers_is_not_reported_verifieda_lock_verifies_only_once_an_anchored_list_holds_its_quorumtick;Evidence
Run at the tip, on
origin/dev2900e46e:cargo fmt --all -- --check,cargo clippy --workspace --all-targets --all-features -- -D warnings,RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps,cargo +1.89 check --workspace --all-features, and typos 1.40.0 on the changed files. All are clean.cargo test --workspace --all-features --no-fail-fastwith the dashd environment set. 2854 passed, 0 failed. The 8 ignored tests are the repo's own#[ignore]s.dashd_masternode: 11/11.dashd_sync: 38/38.dashd_syncsuite no longer exists (chore(dash-spv-ffi)!: remove the dash-spv-ffi crate #1251).-p dash-spv --all-targets --all-features -D warnings) and the dash-spv lib tests pass: 696, 702 and 704.origin/dev.Review
🤖 Generated with Claude Code
PR Hygiene ·
25d1557/self-revieweddash-spv(dash-spv/Cargo.toml,dash-spv/src/client/lifecycle.rs,dash-spv/src/client/queries.rsand 21 more) — QuantumExplorer or ZocoLini or xdustinfacedash/src/sml/llmq_entry_verification.rs) — QuantumExplorer or ZocoLini or xdustinfaceWhen every merge requirement is met, the
PR Hygienecheck passes. Reviewer limits do not block merging; other required GitHub checks and protections still apply.Summary by CodeRabbit