Skip to content

feat(wallet): quorum-by-agreement peer trust so a default install syncs - #209

Merged
MichaelTaylor3d merged 6 commits into
mainfrom
feat/2568-quorum-peer-trust
Aug 10, 2026
Merged

feat(wallet): quorum-by-agreement peer trust so a default install syncs#209
MichaelTaylor3d merged 6 commits into
mainfrom
feat/2568-quorum-peer-trust

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Closes DIG-Network/dig_ecosystem#2568

What changed

A discovered peer could write nothing at all, so a default install never synced: initial_sync_complete stayed false and sync_state.peak_height stayed NULL indefinitely. dig-node is a Chia light client and almost nobody runs their own full node, so that default is the product.

There are no operator-chosen peers to fall back on, so trust is now established by agreement among randomly selected discovered peers. A discovered peer still arrives with no authority; before any write, the supervisor puts a settled-height question to an independently drawn quorum and elevates the session to PeerTrust::Corroborated only if the writer agrees with them — for that session only, never persisted, never cached against an address.

Verified on live mainnet, not asserted: empty peers table, no configuration →
peers=Some(1) first_peak=Some(9130570) latest_peak=Some(9130571). Non-NULL and advancing.

The design decision that matters, and where it came from

The obvious implementation — vote on (height, header_hash) of the tip — is wrong, and reading Sage (xch-dev/sage) is what proved it before it was built. Sage's PeerState::peak() takes the maximum and never votes; try_add_peer admits peers within ±3 blocks and evicts stragglers with the reason recorded as "peer is behind". Honest peers disagree about the tip constantly, so an equality vote splits almost always and stalls the node permanently — a denial of service the node inflicts on itself.

So behind vs lying is resolved in two ordered steps rather than by counting:

  1. eligible() drops peers outside ±3 blocks of the median claim.
  2. common_height() normalises every question to min(sample peaks) − 2.

At a settled height in the shared past, a lagging-but-honest peer and a fully caught-up peer hold the same answer, so lag cannot produce disagreement at all. A peer that still disagrees, at a height it claims to have passed, is lying, partitioned, or forked — never merely slow.

Sage is Apache-2.0 against this crate's GPL-2.0-only. No code was copied; two of its decisions were re-derived and re-implemented in dig-node's terms (the ±3 lag band, and ~5 peers being acceptable light-client load — its default target_peers is 5, so a sample of 4 sits inside that envelope).

Where this deliberately departs from Sage: it syncs from one peer, takes the max, and bans after the fact. That suits an attended desktop wallet. dig-node is an unattended service whose replica feeds spend-path coin selection and whose peak is divided into confirmation counts served over RPC. A maximum is the most attacker-friendly aggregate available — one inflated claim wins outright and raises apparent confirmations — which is the exact inversion sync::PeerTrust already documents. Adopting Sage wholesale would have reverted a finding this crate already paid for.

Threshold: 3 of 4

  • Fewer than 3 cannot express "majority with dissent" — with 2, every disagreement is a bare split carrying no signal.
  • 3-of-3 leaves no margin: one peer mid-reorg stalls sync entirely.
  • 4 costs one extra handshake; Sage ships target_peers: 5.
  • Compile-time asserted a strict majority, so two contradictory answers can never both reach quorum in one round.
Outcome Behaviour
Unanimous Elevate
Majority with dissent (≥3) Elevate, and surface the dissenters — at a settled height a dissenter is not merely behind
Split Write nothing, re-draw a fresh sample. Never takes the plurality. 3 consecutive splits are surfaced as evidence of partition or attack
Insufficient (<4 answered) Write nothing. Never forms a quorum among whoever replied — silencing witnesses must not shrink the quorum an attacker has to capture

Verified locally vs quorum'd vs neither

  • Self-verifying, never voted: coin id (derived from the coin's own fields, never stored from a peer); header-block↔hash binding (HeaderBlock::header_hash() folds its own foliage, so a peer can only send a different block — which is the remaining quorum'd question); genesis challenge / network id.
  • Quorum'd: which header hash is canonical at the settled height.
  • Neither — monotone, fail-closed union: spentness. Any credible SPENT wins even alone; UNSPENT requires the whole sample; a non-answer counts against unspentness. Believing a spent coin spendable is what produces a DOUBLE_SPEND.

Two defects the fixtures caught during development

  • u32::MAX evicted the entire honest set. A max-anchored tolerance band left one liar alone in the pool — total DoS from one unverifiable integer, unrecoverable downstream because nobody was left to vote. Fixed by anchoring on the median (mutation M7).
  • Elevated trust never reached the floor check. catch_up read self.trust (the dial source), so a corroborated peer was still refused by initial_sync_with. Fixed by threading effective trust through SyncSession::catch_up (mutation M11).

Blast radius checked

Measured with ripgrep + direct reads (the gitnexus MCP tools are not exposed to this lane; §2.0 permits the documented fallback and requires it be stated).

PeerTrust and is_authoritative are referenced only inside dig-wallet::sage::{sync, sync_supervisor} — 5 is_authoritative call sites, one exhaustive match, zero cross-crate consumers. is_authoritative was converted from matches! to an exhaustive match, so adding a variant without deciding its authority is now a compile error.

Risk: MEDIUM — money-adjacent, but contained to one module pair, and every write site was already funnelled through the single predicate. No HIGH/CRITICAL finding.

Signature changes (internal to the crate, no external consumers): SyncSession gains header_hash_at, and catch_up gains a trust parameter.

Tests — every one proved load-bearing

CI on this exact commit (10a1f4f) — every required check green:

Check Result
Test + coverage 1,824 tests, 0 failed (23m10s)
Coverage workspace 86.49% region / 87.68% line; sage/quorum.rs 98.01% region, 100% function, 95.24% line
Clippy pass (5m2s)
Rustfmt pass
CodeQL (rust / actions / js-ts) pass
Check version increment pass
Lint commit messages pass
build .msi / .pkg / .deb x2 pass

The test count and the per-test PASS lines were read out of the job log rather than inferred from the check's name and colour: a "Test + coverage" check has previously gone green in 29s having executed zero tests, so the log was checked for the actual sage::quorum::tests::* and sage::sync_supervisor::tests::* entries.

Mutation battery: 12 run, 12 killed, 0 survived.

# Mutation Killed by
M1 Corroborated loses authority default-install sync, coin-selection routing
M2 may_elevate always true lying writer, split, declining writer, unreachable quorum
M3 may_elevate ignores the writer's answer lying writer, declining writer, elevation table
M4 tally takes the plurality split test, threshold-both-sides
M5 tally counts responders not sample insufficient test
M6 modulo instead of rejection sampling out-of-zone draw
M7 eligible anchors on maximum inflated claim, symmetric band
M8 short report set reads as unspent unproven spentness
M9 common_height uses max question-below-every-peak
M10 sampling with replacement never-drawn-twice, order-independence
M11 dial trust passed to catch_up default-install sync, coin-selection routing
M12 corroboration refusal ignored lying writer, split, declining writer, unreachable quorum

Fixture design notes (where false greens come from):

  • The lying-writer test asserts two hopscatch_up_count() == 0 and an empty replica — because the fix is a placement. Asserting only the empty replica would be satisfied identically by the old guard down in initial_sync_with, and a refactor moving it would keep such a test green.
  • Exactly one actor varies: the quorum is honest and unanimous, only the writer lies. An all-hostile fixture reads as harsher and is precisely the one that cannot see a missed check.
  • The split fixture is 2-1-1, not 2-2 — a 2-2 has no plurality, so a plurality-taking implementation would also decline it and the test would pass against the defect it claims to exclude.
  • The insufficient fixture has three unanimous responders, which clears the agreement threshold on its own — so it separates "tally against the sample" from "tally against whoever replied".
  • CSPRNG is pinned structurally, not statistically: subsec_nanos() (the nearest wrong source, already used by jitter() in this same file) cannot produce draws above 2^30, so the test asserts something the wrong source is incapable of.

Docs

SPEC.md §18.6c–e rewritten: the three-level trust model, the numbered corroboration rule, the outcome table, self-verifying vs quorum'd vs monotone, and the Sybil limit.

Known limits — stated in SPEC.md, not hidden

  • The Sybil model is a cost multiplier, not a barrier. An attacker controlling fraction f carries a round with probability ≈ 0.4% at f=0.1, 8% at f=0.3, 31% at f=0.5. As f → 1 the model provides no protection. The published numbers are pinned by a test against the code that computes them.
  • Denial is cheaper than forgery: 2 hostile of 4 force a split and stall the write; 3 are needed to forge one. Deliberate — a stalled sync is visible and recoverable, a forged one is neither.
  • connect_random_peer is biased. It tries 127.0.0.1:8444 before any introducer unconditionally, then returns the first address that connects — so a co-resident process is returned by every call, and four calls would be four connections to one attacker producing a "unanimous" verdict from a sample of one. Compensated by requiring distinct addresses within a round (bounded retry → Insufficient → no write). Distinctness stops one peer supplying a whole quorum; it does not equalise the draw. Resolving the introducer list once and drawing with select_sample is the stronger form and is a follow-up.
  • initial_sync_complete was not exercised on mainnet — the live fixture holds no wallet, so §18.6's empty-set invariant correctly refuses to latch it. Proven in unit form only.

Follow-ups worth ticketing

  1. connect_random_peer probes localhost first, unconditionally — affects the writer session too, not just corroboration.
  2. Unbiased quorum selection from the resolved address list rather than repeated first-responder dials.
  3. #2551 is narrower but not closed — flipping the DialSource argument still grants Operator outright, bypassing corroboration entirely.
  4. chia-query's peer_then_coinset router remains a single-source read; this PR corroborates the sync path only.
  5. SpentEvidence is built and pinned but not yet wired into coin selection — the spend path currently inherits safety from the corroborated replica, not from the union rule.
  6. A u32::MAX peak claim defeats any max-anchored peer filter; worth sweeping other DIG peer-selection code.

🤖 Generated with Claude Code

@MichaelTaylor3d
MichaelTaylor3d force-pushed the feat/2568-quorum-peer-trust branch from 10a1f4f to 2e49b18 Compare August 10, 2026 18:35
MichaelTaylor3d and others added 6 commits August 10, 2026 11:59
Co-Authored-By: Claude <noreply@anthropic.com>
… (#2568)

Co-Authored-By: Claude <noreply@anthropic.com>
…d plainly (#2568)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…s and follows the chain (#2568)

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the feat/2568-quorum-peer-trust branch from 2e49b18 to 630432c Compare August 10, 2026 19:00
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 10, 2026 19:26
@MichaelTaylor3d
MichaelTaylor3d merged commit 4517c90 into main Aug 10, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the feat/2568-quorum-peer-trust branch August 10, 2026 19:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant