fix(mirror): funding-audit residue — skip count, rival FundingObservation, alert-storm latch (#481) - #529
Conversation
…r-movable counts `FundingAlertGate::observe` suppressed a repeat unmeasured alert only when the whole `UnmeasuredFunding` value compared equal. `AuthenticationTruncated` carries `skipped` -- `MAX_AUTHENTICATION_ATTEMPTS` minus however many honest coins the bounded walk reached -- and the walk runs over a PUBLIC puzzle hash, so a stranger moves that number by paying one more coin to the operator's $DIG address. The reason value therefore changed between passes, the gate never latched, and a one-time dust spend bought 144 desktop alerts a day: exactly the stream the gate exists to prevent, and the surest way to train an operator to dismiss the alert that matters. Latching now compares `UnmeasuredFunding::alert_key` -- the discriminant plus only those payload fields no stranger can move. `NoCreateAffordable`'s `need_dig_base_units` is derived from the epoch requirement and the plan rather than from the wallet, so it stays IN the key: a changed collateral requirement is a change in what the operator must do, and swallowing it would be its own money defect. `AuthenticationTruncated`'s counts drop out of the key and remain fully available to the alert body, because an operator who is being told still deserves the real figures. The change is private to `funding.rs`: the field type and the comparison, not the public signature of `observe`. Refs dig-node#481 Co-Authored-By: Claude <noreply@anthropic.com>
…it once
`FundingSelection.skipped` was computed on every selection and had ZERO
production consumers: the mirror lifecycle funded creates through
`select_operator_dig_cats`, whose whole body discards it. The module's own
doc claimed "a skip is counted and reported, never swallowed", and that was
true of the tests and false of the shipped node.
It matters because the same path that passes over a stranger's coin passes over
one of this node's OWN coins when lineage handling has a bug. Production then
refused with `Insufficient { have_dig_base_units }` -- a confident, understated
figure. Unknown is not zero, and an operator reading an unmeasured balance as a
measured one tops up money they already hold.
Three changes:
* The lifecycle funds through `select_operator_dig_cats_detailed` and consumes
the skips, naming the store the selector cannot know. It reports on the
SUCCESS path too, not only where `CandidatesUnverifiable` already reached the
operator -- a funded pass that passed candidates over has still established
only a floor.
* `skip_report` is the one operator-facing sentence, and it says the total is a
FLOOR: "at least that much, not exactly that much", plus what to investigate.
A report that quoted the figure flat would restate the lie in a new place.
* The per-candidate `tracing::warn!` inside the walk is gone. It was up to
`MAX_AUTHENTICATION_ATTEMPTS` lines per selection per create per pass -- about
18,400 a day at one store -- and the count was set by whoever planted the
coins, in a module with no rate limit. The walk now reports ONCE, with the id
list capped at `SKIP_SAMPLE`, so neither the line count nor the line length is
a figure an attacker chooses.
The doc quoted above now describes what the code does.
Refs dig-node#481
Co-Authored-By: Claude <noreply@anthropic.com>
…dedEvidence Two types in this crate were called `FundingObservation`, and they are not rivals to centralize -- they are different concepts that happened to share a name (dig-node#481): | | `wallet_funded` | `mirror::funding` | |---|---|---| | subject | the node-custodied wallet | the operator wallet | | question | has it EVER held money | what is spendable THIS pass | | decides | `autoseed::latch_ever_funded` | the operator alert gate | | lifetime | monotonic, permanent | per-pass | Merging them would collapse the node-wallet/operator-wallet boundary that `mirror::funding`'s module doc exists to protect, which is the money lie that module was written to prevent. So the fix is a rename, and the name now states what the type actually decides: it is evidence the wallet has ever held money, not a measurement of funding. The richer, newer `mirror::funding` type keeps the name, which reads correctly for a per-pass measurement. The two are cross-referenced from the renamed type's doc so the next reader cannot re-derive the confusion. Blast radius: `wallet_funded.rs` (18 references, its own module and tests) and `server.rs` (4, one a doc comment) -- the complete set. The gitnexus index for this repo is 338 commits behind, which returns a false-safe empty impact, so the radius was established by grep with a controlled pattern instead. Refs dig-node#481 Co-Authored-By: Claude <noreply@anthropic.com>
…d bound the skip report Two clauses backfilled for behaviour changed in this branch (dig-node#481), so SPEC stops describing a node that no longer exists. 25.12 required an unmeasured observation to raise "once on entering it" and said nothing about what makes two such observations the same condition. The implementation compared the whole value, including the truncated walk's skipped count -- which a stranger moves by paying one coin into the publicly derivable operator address, so the suppression never applied and the attacker set the notification rate. The clause now requires attacker-movable fields to be excluded from that comparison, and requires a re-raise when a figure the operator must act on changes and no stranger can move it, since suppressing that is under-reporting rather than repeat-suppression. 25.11's "counted and reported" is now specific about the report: one bounded message per selection rather than one per candidate, an id list that is itself bounded, reachable on the funding path the node actually uses including where the selection succeeds, and framing any total as a floor. Co-Authored-By: Claude <noreply@anthropic.com>
|
Progress (implementer lane, resume-ready). Branch All three fixes are implemented, committed separately and pushed. Version left at 0.252.32 as instructed; PR body untouched.
Targeted runs green before the target dir was poisoned: NEXT ACTION: a full |
… 0.252.92) Co-Authored-By: Claude <noreply@anthropic.com>
… 0.252.92) Co-Authored-By: Claude <noreply@anthropic.com>
|
IN PROGRESS — not the verdict (loop-security audit, PR #529, head Confirmed so far on the primary target (alert-latch fix,
Independent Still to check: |
|
PASS Primary target -- the alert-latch fix (
|
|
Independent build/test run completed after the verdict above was posted -- corroborates it.
21/21, matching the PR's own claim exactly, and the 777-filtered-out count confirms this was a real full-crate test scan (not a filter matching zero tests, which would print This does not change the verdict above (PASS) -- it upgrades the hand-traced verification from "would fail under the pre-fix code" to "does pass under the actual fix, on a real build." |
fix(mirror): funding-audit residue — discarded skip count, rival FundingObservation, alert-storm latch (dig-node#481)
Scope, corrected from the original title
Originally opened to cover #527, #513 and #481 as one unit on the premise they shared a seam. On
inspection #527 and #513 carry zero lines of code in this PR — both are entirely downstream of
peer_declaration's hardcodedNotReadablestub onorigin/main(
crates/dig-node-service/src/mirror/bond_verify.rs:145-150), which makesdeclaration_source_is_readable()alwaysfalseand short-circuitsverdict_forbefore any chainread. Their findings are real but unreachable until PR #501 (
loop/473-peer-binding; that PR carries its own closing reference to issue 466, unrelated to this PR)lands and gives that function a chain to read. Verdicts with cited
file:lineposted on#527 and
#513 — both held for
#501, neither closed.
This PR now covers #481 items 2 and 3, plus one previously-unticketed defect found while working
the file (an alert-storm bug in the gate item 1's investigation touches). #481 item 1 (the
per-PASS authentication budget / coin-id memoisation) is deliberately not in this PR — the shape
is decided (see the ticket's 2026-09-03 comment) but not built — so #481 stays open, scoped to that
one remaining item. Full disposition on
#481.
What changed
select_operator_dig_catscallers now consume the skip count.lifecycle.rs's onlyproduction caller switches to
select_operator_dig_cats_detailed; a single boundedskip_report(funding.rs:334-378, up toSKIP_SAMPLE = 8coin ids) replaces a per-candidatetracing::warn!that could emit up to 128 lines per selection, attacker-driven volume with norate limit anywhere in the module. Any total the pass reports is now explicitly framed as a FLOOR.
wallet_funded::FundingObservationrenamed toEverFundedEvidence. It andmirror::funding::FundingObservationare different concepts (node-wallet-ever-funded vs.operator-wallet-this-pass-health) sharing a name; merging them would have collapsed a distinction
the module exists to protect, so this is a rename, not a centralization.
FundingAlertGatenow latches on(discriminant, non-attacker-movable fields), not the wholevalue.
AuthenticationTruncated{attempted, skipped}carriedskipped, which a stranger moves bypaying one more coin into the operator's public $DIG address — so the gate never actually latched
and could alert the operator up to 144x/day off a one-time dust spend.
NoCreateAffordable'sneed_dig_base_unitsstays in the key (plan-derived, not attacker-movable — suppressing a genuinechange there would itself be a money defect).
Verified
cargo build -p dig-node-service --tests— clean, after mergingorigin/main(0.252.80 → thisbranch takes 0.252.92, above every other in-flight branch as of this push).
cargo test -p dig-node-service --lib mirror::funding::— 21 passed, 0 failed.cargo test -p dig-node-service --test mirror_operator_funding— 14 passed, 0 failed.cargo test -p dig-node-service --lib wallet_funded::— 3 passed, 0 failed.origin/main; only the version line, resolved bymerge-main-keep-version.sh.Single-writer note re #501
funding.rsis byte-identical between this branch's merge-base andorigin/main, and PR #501 doesnot touch it. This PR does touch
lifecycle.rsandserver.rs, both also touched by #501 —FundingAlertGate::observe's public signature is unchanged, so #501'spass.rs/runner.rscallsites are unaffected; the
lifecycle.rs/server.rsoverlap is textual (adjacent lines) and will bean ordinary merge conflict for whichever of the two merges second, not a collision on the same
edit.
Closing keywords
None. #527 and #513 are held for #501 (not this PR's work). #481 keeps 1 of 3 items open (item 1),
so it is referenced, not closed, until that item lands.