feat(wallet): quorum-by-agreement peer trust so a default install syncs - #209
Merged
Conversation
MichaelTaylor3d
force-pushed
the
feat/2568-quorum-peer-trust
branch
from
August 10, 2026 18:35
10a1f4f to
2e49b18
Compare
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
force-pushed
the
feat/2568-quorum-peer-trust
branch
from
August 10, 2026 19:00
2e49b18 to
630432c
Compare
MichaelTaylor3d
marked this pull request as ready for review
August 10, 2026 19:26
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes DIG-Network/dig_ecosystem#2568
What changed
A discovered peer could write nothing at all, so a default install never synced:
initial_sync_completestayed false andsync_state.peak_heightstayed 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::Corroboratedonly if the writer agrees with them — for that session only, never persisted, never cached against an address.Verified on live mainnet, not asserted: empty
peerstable, 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'sPeerState::peak()takes the maximum and never votes;try_add_peeradmits 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:
eligible()drops peers outside ±3 blocks of the median claim.common_height()normalises every question tomin(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_peersis 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::PeerTrustalready documents. Adopting Sage wholesale would have reverted a finding this crate already paid for.Threshold: 3 of 4
target_peers: 5.Verified locally vs quorum'd vs neither
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.Two defects the fixtures caught during development
u32::MAXevicted 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).catch_upreadself.trust(the dial source), so a corroborated peer was still refused byinitial_sync_with. Fixed by threading effective trust throughSyncSession::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).
PeerTrustandis_authoritativeare referenced only insidedig-wallet::sage::{sync, sync_supervisor}— 5is_authoritativecall sites, one exhaustivematch, zero cross-crate consumers.is_authoritativewas converted frommatches!to an exhaustivematch, 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):
SyncSessiongainsheader_hash_at, andcatch_upgains atrustparameter.Tests — every one proved load-bearing
CI on this exact commit (
10a1f4f) — every required check green:sage/quorum.rs98.01% region, 100% function, 95.24% lineThe 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::*andsage::sync_supervisor::tests::*entries.Mutation battery: 12 run, 12 killed, 0 survived.
Corroboratedloses authoritymay_elevatealways truemay_elevateignores the writer's answertallytakes the pluralitytallycounts responders not sampleeligibleanchors on maximumcommon_heightuses maxcatch_upFixture design notes (where false greens come from):
catch_up_count() == 0and an empty replica — because the fix is a placement. Asserting only the empty replica would be satisfied identically by the old guard down ininitial_sync_with, and a refactor moving it would keep such a test green.subsec_nanos()(the nearest wrong source, already used byjitter()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
fcarries a round with probability ≈ 0.4% atf=0.1, 8% atf=0.3, 31% atf=0.5. Asf→ 1 the model provides no protection. The published numbers are pinned by a test against the code that computes them.connect_random_peeris biased. It tries127.0.0.1:8444before 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 withselect_sampleis the stronger form and is a follow-up.initial_sync_completewas 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
connect_random_peerprobes localhost first, unconditionally — affects the writer session too, not just corroboration.DialSourceargument still grantsOperatoroutright, bypassing corroboration entirely.chia-query'speer_then_coinsetrouter remains a single-source read; this PR corroborates the sync path only.SpentEvidenceis 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.u32::MAXpeak claim defeats any max-anchored peer filter; worth sweeping other DIG peer-selection code.🤖 Generated with Claude Code