Skip to content

fix(mirror): bond-verification budget hardening (#527) + aggregate authentication budget (#481) - #535

Merged
MichaelTaylor3d merged 6 commits into
mainfrom
loop/527-481-followup
Sep 3, 2026
Merged

fix(mirror): bond-verification budget hardening (#527) + aggregate authentication budget (#481)#535
MichaelTaylor3d merged 6 commits into
mainfrom
loop/527-481-followup

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #527. Closes #481 (its item 1, the last remaining item -- items 2/3 already shipped in #529).

Both tickets, in full: dig-node#527 (all four items) and dig-node#481 item 1.

Both tickets were correctly deferred while bond_verify.rs's chain-verification leg was dead code (peer_declaration hardcoded to NotReadable). #501 (merged as 0a01c671) replaced that stub, and an independent re-audit posted per-item verdicts on both tickets on 2026-09-03 confirming all findings are now LIVE and REACHABLE. This PR fixes them.

What changed

dig-node#527 item 1 — repeat-coin-id ledger bypass (HIGH). ReadAdmission::admit let a REPEAT of an already-seen unproven coin id skip the per-claimant distinct-id cap and cost only the process-wide token bucket. A coin that never declares its claimant returns Unverified, which is deliberately never cached — so one fabricated (peer_id, coin_id) pair, sustained at the bucket's ~1/sec refill, could hold the entire process-wide verification budget at zero forever, silencing every other claimant's bond verification. Fixed with a per-(claimant, coin_id) retry cooldown (UNPROVEN_COIN_RETRY_COOLDOWN = VERDICT_TTL), independent of the distinct-id ledger.

dig-node#527 item 2 — budget consulted after a read, not before (LOW-MEDIUM). current_requirement() (a synchronous file read + JSON parse) was evaluated as an eager function argument to verify_against_chain, which Rust runs before admit() — so a claim the budget was always going to refuse still paid the read. admitted_verdict_for now takes required_collateral: impl FnOnce() -> Option<u64> and only calls it after admit() succeeds.

dig-node#527 item 3 — stale doc (LOW, docs-only). VERIFICATION_BURST's doc still described "two blocking HTTPS reads through the node's ONE shared ChiaQuery client," contradicting the shipped call site, which reads through corroborated_chain_source (peer-corroborated, api.coinset.org first) since #503. Corrected.

dig-node#527 item 4 — floor vacuity below 3 peers (HIGH, structural). The shared DialedPeerSample cache both the sync path (floor 2) and the bond path (floor 3) draw from only checked the SYNC floor for reusability, so a 2-peer sample reported itself "usable" while tally_with_floor(_, 3) can only ever produce Insufficient → Unverified from it — every bond claim in that window paid a token, a ledger slot and a real 2-peer query round for a structurally guaranteed non-answer. Added PeerSample::live_count_hint() (default None, so no other implementor's behaviour changes) — a cheap, non-dialling peek — and a pre-check in verify_against_chain that skips the read when the hint proves the sample cannot meet the caller's floor. Deliberately does not force an extra redraw on a floor miss: that would trade one wasted read for a redial storm against the same thin network on every subsequent claim, which is worse than the defect it fixes (network topology is what it is; a redraw wouldn't change the outcome, only the cost).

dig-node#481 item 1 — aggregate authentication budget (HIGH → the real severity is availability, see the ticket's own decision comment). MAX_AUTHENTICATION_ATTEMPTS (128) bounded one create() call, not the pass, and — as the decision comment on #481 measured — the create loop breaks on first failure, so at ≥128 planted coins a stranger can permanently stop a node from bonding at all, off a one-time ~0.00013 XCH spend. Per the shape already decided on the ticket: added AuthVerdict::{Final,Transient} to authenticate's refusal (four of its five reasons are pure functions of immutable chain data and can never flip for the same coin id; "its creating spend is not on chain" stays Transient and is never cached, since a lagging/reorging source can flip it to Some on a later read — caching it would permanently blacklist a coin the node genuinely owns). Added a process-wide AuthMemo that remembers Final refusals by coin id, pruned each selection to the intersection with that selection's own live pool (len() <= pool.len(), plus a 50,000-entry backstop).

Failure direction of every bound touched

  • Item 1's cooldown: fails toward re-asking too rarely, never toward wrongly promoting a bond (a genuine coin re-declares within VERDICT_TTL anyway).
  • Item 4's pre-check: fails OPEN on None (nothing drawn yet) into the real read — never reads a hint as a refusal. Deliberately does not add a new redraw trigger, to avoid a redial storm.
  • mirror funding: per-PASS authentication budget, the discarded skip count, and the rival FundingObservation #481's memo: only Final (immutable-data) refusals are ever cached; Transient is a hard type-level exclusion (AuthVerdict has no Default, no exhaustive-avoiding escape — every call site names a variant), so a future refusal reason cannot silently inherit "cache forever" by omission. A full memo refuses NEW entries rather than evicting held ones (same anti-amplification shape as bond_verify's own VerdictCache).

SPEC.md

§25's per-claimant ledger clause now states the repeat cooldown; a new clause states the peer-sample floor pre-check and its no-forced-redraw rule.

Evidence

  • cargo build -p dig-node-service (+ dig-wallet): clean, exit 0.
  • cargo clippy -p dig-node-service -p dig-wallet --lib --tests -- -D warnings: clean, exit 0 (one type_complexity finding fixed via a ClaimantLedger type alias).
  • rustfmt --check on every touched file: clean.
  • cargo test -p dig-node-service --lib: 810 passed, 0 failed, 0 filtered (full crate, no filter — count checked, not just exit status).
  • cargo test -p dig-wallet --lib sage::: 718 passed, 0 failed, 1 ignored (pre-existing), 83 filtered (filter excludes non-sage tests in the crate; nonzero passed confirms the filter matched).
  • 4 new regression tests, all passing: a_repeated_unproven_coin_id_stops_costing_a_fresh_read_every_attempt, a_sample_below_the_required_floor_is_recognised_without_a_read, a_finally_refused_coin_is_recalled_and_pruned_with_the_pool, the_memo_bound_refuses_new_entries_rather_than_evicting_held_ones.
  • Rebuilt + retested after merging origin/main (picked up fix(peer): count accepted inbound peers in the connected pool #402, test(peer): prove bring-up installs the downstream engines on the real genesis (#240) #533): still 810/810.

Blast radius

dig-node-service::mirror::{bond_verify,funding} (both touched modules already own this logic — no new module). dig-wallet::sage::{peer_reads,peer_reads::dialed,corroborated_source}: added one default trait method (PeerSample::live_count_hint, default None — zero behaviour change for ScriptedTips/NoPeers/ScriptedSample test doubles, confirmed by dig-wallet's full sage:: suite staying green) and two small inherent methods (PeerCorroboratedReads::live_count_hint, CorroboratedChainSource::{required_floor,live_peer_hint}) — no existing signature changed. dig-wallet is a path dependency internal to this repo's own workspace (not a published modules/crates/ crate), so no separate crate version bump applies.

Scope note

This PR closes #527 in full and the LAST remaining item of #481 (items 2/3 shipped in #529). It does not touch #513 (already closed) or anything outside these two tickets.

Co-Authored-By: Claude noreply@anthropic.com

MichaelTaylor3d and others added 6 commits September 3, 2026 10:24
Co-Authored-By: Claude <noreply@anthropic.com>
…ation memo

dig-node#527 (4 items) + dig-node#481 item 1, re-verified reachable against
merged main (0a01c67, dig-node#501) by an independent audit posted on both
tickets 2026-09-03.

#527 item 1: a repeated unproven coin id bypassed the per-claimant distinct-id
ledger and cost only the process-wide token bucket, so one fabricated coin
sustained at ~1 req/sec could hold ReadAdmission::shared()'s entire budget at
zero forever. Fixed with a per-(claimant, coin_id) retry cooldown
(UNPROVEN_COIN_RETRY_COOLDOWN = VERDICT_TTL) alongside the existing distinct
cap.

#527 item 2: current_requirement() (a sync fs read + JSON parse) was evaluated
as an eager argument to verify_against_chain, before admit() ever ran --
paying the read for a claim the budget was always going to refuse. admit()
now gates a THUNK (impl FnOnce() -> Option<u64>) rather than a value, so the
read is deferred to exactly the branch that needs it.

#527 item 3: corrected the VERIFICATION_BURST doc, which still described a
bare "ONE shared ChiaQuery client" read while the shipped call site has read
through corroborated_chain_source (peer-corroborated, api.coinset.org first)
since dig-node#503.

#527 item 4: a bond claim paid a full corroborated query round even when the
node's own currently-held peer sample structurally could not meet
BOND_CORROBORATION_FLOOR (3) -- DialedPeerSample::still_usable only enforced
the weaker sync floor (2). Added PeerSample::live_count_hint(), a cheap
non-dialling peek (default None, so no other implementor's behaviour
changes), and a pre-check in verify_against_chain that skips the read when
the hint proves it cannot succeed. Deliberately does NOT force an extra
redraw on a floor miss -- that would trade one wasted read for a redial
storm against the same thin network, which is worse.

#481 item 1: MAX_AUTHENTICATION_ATTEMPTS (128) bounded one create() call, not
the pass, so K attempted bonds paid up to K x 128 chain reads against the
SAME planted-coin pool. Per the loop-decider shape already settled on the
ticket: added AuthVerdict::{Final,Transient} to authenticate()'s refusal (the
"creating spend not yet on chain" case stays Transient and is never cached,
since it can flip on a later read), and a process-wide AuthMemo that
remembers FINAL refusals by coin id, pruned to the intersection with each
selection's own live pool (bound: len() <= pool.len(), 50k backstop).

SPEC.md SS25 updated: the per-claimant ledger clause now states the repeat
cooldown, and a new clause states the peer-sample floor pre-check.

Tests: 4 new regression tests (bond_verify: repeat-coin-id cost, floor
pre-check; funding: memo recall/prune, memo bound-vs-eviction). Full
dig-node-service --lib: 810/810. dig-wallet --lib sage::: 718/718 (1 ignored,
pre-existing). rustfmt clean on touched files. clippy -D warnings pending
final confirmation.

Co-Authored-By: Claude <noreply@anthropic.com>
…oop/527-481-followup

Co-Authored-By: Claude <noreply@anthropic.com>

# Conflicts:
#	Cargo.lock
#	Cargo.toml
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 3, 2026 19:37
@MichaelTaylor3d
MichaelTaylor3d merged commit 7042f89 into main Sep 3, 2026
14 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/527-481-followup branch September 3, 2026 19:37
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
Merged origin/main forward (7042f89, #535) rather than rebasing -- the queue
across the five open PRs sequences this one last (#542 0.254.42, #539 0.254.43,
#543 0.254.44, #536 0.254.50, this PR 0.254.51), deliberately: #542 touches
ensure-version-increment.yml, which this PR's follow-up note also targets, so
landing last means the final shape is on main rather than guessed at.

Re-read the version from Cargo.toml on disk (not the commit log) after the
merge, per the standing caution that a rebase can silently drop a bump commit
as "already upstream" -- this was a merge, and the version file itself
confirms 0.254.51 post-bump.

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
Per the main lane: main advanced to exactly 0.254.41 after #535's rebase,
tying this branch's version. 0.254.43 clears main and every sibling PR in
the version-bump queue (#542=0.254.42, #543=0.254.44, #536=0.254.50,
#544=0.254.51).

Cargo.lock re-synced with `git checkout origin/main -- Cargo.lock` followed
by `cargo update -w --offline` (never hand-editing lock conflict markers),
confirmed clean with `--dry-run` -> `Locking 0 packages`.

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
#539)

* fix(wallet): discipline reservation liveness against a monotonic clock

Reservation deadlines (#502/#525/#528) are anchored entirely on wall-clock
readings. #528 closes the case where the clock is already wrong at the
moment a reservation is FIRST written. It does not close the general form
(#532): a wall clock stepped FORWARD while a reservation is already live,
mid-hold -- an NTP step, a VM pause/resume, an operator setting the clock --
produces no self-contradiction for #528's check to catch, yet the very next
prune reads the jump as elapsed time and can retire a bundle's hold while it
is still genuinely in flight, with no bound on how far forward the step
goes (the #348/#497 double-spend direction).

Add ClockGovernor: it disciplines every reservation-lifecycle "now" reading
so it cannot advance, between two observations, faster than a monotonic
clock says real time has actually elapsed. A forward wall-clock jump is
absorbed rather than trusted and the disciplined clock simply runs behind
until real time catches up, at which point it resumes tracking the wall
clock with no special unfreeze step. A backward step is passed straight
through unclamped, since it can only lengthen a hold, never shorten one --
the safe direction #502/#528 already accept elsewhere.

The governor lives for the process's lifetime and is not persisted: a
restart re-seeds it from the wall clock at that moment, so a clock already
wrong at boot remains #528's write-time contradiction check's problem, not
this one's.

Closes #532

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(release): bump workspace version to 0.254.20

Root workspace version, per the main lane -- the minor field's version scheme
is being fixed separately under #521/#522; this is the interim number to
carry PR #539 (dig-node#532) through the version-increment gate.

Cargo.lock refreshed in the same commit (cargo update -w --offline) so
dig-node-service's locked entry matches -- every CI job runs --locked, and a
manifest-only bump here fails Clippy/Test+coverage/all three package builds
together on a change that cannot otherwise break a build.

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(release): bump workspace version to 0.254.43

Per the main lane: main advanced to exactly 0.254.41 after #535's rebase,
tying this branch's version. 0.254.43 clears main and every sibling PR in
the version-bump queue (#542=0.254.42, #543=0.254.44, #536=0.254.50,
#544=0.254.51).

Cargo.lock re-synced with `git checkout origin/main -- Cargo.lock` followed
by `cargo update -w --offline` (never hand-editing lock conflict markers),
confirmed clean with `--dry-run` -> `Locking 0 packages`.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
…bump (#544)

* fix(release): guard the MSI cross-release ordering hazard at a MAJOR bump

scripts/package-version.sh's MINOR-overflow carry (#537, closing #521/#522) folds an
overflowing MINOR into the idle MSI major field and is monotonic for the whole
MAJOR==0 lifetime, but it cannot see a cross-release hazard: a carried release like
0.511.0 (MSI 1.255.0) can compare HIGHER under msiexec's numeric ordering than a
later, perfectly legal 1.0.0 (MSI 1.0.0). An in-version guard can't catch this --
1.0.0 alone is not illegal, only a specific predecessor makes it a downgrade.

Add an optional second argument, PREV_VERSION (the previous stable release's bare
X.Y.Z). When supplied, the script folds it with the same rule and refuses to emit
a version whose MSI tuple compares LOWER than PREV_VERSION's -- turning the "pick a
high-enough MAJOR-bump number" decision into a machine-checked one instead of an
unchecked human call. Omitting it (every existing package.yml callsite) is
byte-for-byte unchanged; ensure-version-increment.yml already checks out both the
PR head and main, so a follow-up can wire main's version in at zero extra cost
(sequenced separately against #542, which is adding a step to that same job).

Measured (dig-node#540): MINOR has never exceeded 255 in any released dig-node
version, so nothing shipped is affected today -- this closes the hazard before it
can ever be reached rather than reacting once it is.

Also corrects "dig_ecosystem#521/#522" references in this script/test/SPEC to
"#521/#522" -- those are dig-node's own issues, not the unrelated dig_ecosystem
tickets of the same numbers.

Revert-proof: stripping the guard reproduces the exact hazard as an accepted exit 0
(0.511.0 -> 1.0.0), and reinstating it passes the full suite (58 ok / 0 fail).

Closes #540

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(release): re-bump to 0.254.51 -- main took 0.254.41 (#535)

Merged origin/main forward (7042f89, #535) rather than rebasing -- the queue
across the five open PRs sequences this one last (#542 0.254.42, #539 0.254.43,
#543 0.254.44, #536 0.254.50, this PR 0.254.51), deliberately: #542 touches
ensure-version-increment.yml, which this PR's follow-up note also targets, so
landing last means the final shape is on main rather than guessed at.

Re-read the version from Cargo.toml on disk (not the commit log) after the
merge, per the standing caution that a rebase can silently drop a bump commit
as "already upstream" -- this was a merge, and the version file itself
confirms 0.254.51 post-bump.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant