Skip to content

fix(mirror): funding-audit residue — skip count, rival FundingObservation, alert-storm latch (#481) - #529

Merged
MichaelTaylor3d merged 7 commits into
mainfrom
loop/527-513-481-mirror-residue
Sep 3, 2026
Merged

fix(mirror): funding-audit residue — skip count, rival FundingObservation, alert-storm latch (#481)#529
MichaelTaylor3d merged 7 commits into
mainfrom
loop/527-513-481-mirror-residue

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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 hardcoded NotReadable stub on origin/main
(crates/dig-node-service/src/mirror/bond_verify.rs:145-150), which makes
declaration_source_is_readable() always false and short-circuits verdict_for before any chain
read. 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:line posted 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_cats callers now consume the skip count. lifecycle.rs's only
    production caller switches to select_operator_dig_cats_detailed; a single bounded
    skip_report (funding.rs:334-378, up to SKIP_SAMPLE = 8 coin ids) replaces a per-candidate
    tracing::warn! that could emit up to 128 lines per selection, attacker-driven volume with no
    rate limit anywhere in the module. Any total the pass reports is now explicitly framed as a FLOOR.
  • wallet_funded::FundingObservation renamed to EverFundedEvidence. It and
    mirror::funding::FundingObservation are 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.
  • FundingAlertGate now latches on (discriminant, non-attacker-movable fields), not the whole
    value.
    AuthenticationTruncated{attempted, skipped} carried skipped, which a stranger moves by
    paying 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's
    need_dig_base_units stays in the key (plan-derived, not attacker-movable — suppressing a genuine
    change there would itself be a money defect).
  • SPEC.md §25.11/25.12 updated to state both behaviors normatively.

Verified

  • cargo build -p dig-node-service --tests — clean, after merging origin/main (0.252.80 → this
    branch 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.
  • No non-mechanical merge conflicts against origin/main; only the version line, resolved by
    merge-main-keep-version.sh.

Single-writer note re #501

funding.rs is byte-identical between this branch's merge-base and origin/main, and PR #501 does
not touch it. This PR does touch lifecycle.rs and server.rs, both also touched by #501
FundingAlertGate::observe's public signature is unchanged, so #501's pass.rs/runner.rs call
sites are unaffected; the lifecycle.rs/server.rs overlap is textual (adjacent lines) and will be
an 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.

Salvage anchor for dig-node#527, #513 and #481. Version claimed above every
in-flight branch (highest was 0.252.27 on PR #524).

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d and others added 4 commits September 3, 2026 01:25
…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>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Progress (implementer lane, resume-ready). Branch loop/527-513-481-mirror-residue, head 7e7416e6.

All three fixes are implemented, committed separately and pushed. Version left at 0.252.32 as instructed; PR body untouched.

  • 546593bc Fix 1 - the alert gate latches on UnmeasuredFunding::alert_key (discriminant + only non-attacker-movable fields), not on the whole value. Private to funding.rs; observe's signature is unchanged, so no collision with feat(mirror): activate bond promotion on the coin's own peer declaration #501's pass.rs/runner.rs/server.rs hunks.
  • 5a71fbe1 Fix 2 - lifecycle.rs funds via select_operator_dig_cats_detailed and consumes skipped; the per-candidate tracing::warn! is replaced by one bounded skip_report per selection that frames any total as a FLOOR.
  • 4fa8702a Fix 3 - wallet_funded::FundingObservation renamed to EverFundedEvidence, with a table in its doc distinguishing it from mirror::funding::FundingObservation. Rename only; the two are NOT merged.
  • 7e7416e6 SPEC 25.11/25.12 backfilled so the contract describes the new behaviour.

Targeted runs green before the target dir was poisoned: mirror::funding::tests 21 passed / 0 failed; mirror_operator_funding 14 passed / 0 failed. Mutation proofs observed RED for both load-bearing tests.

NEXT ACTION: a full cargo test -p dig-node-service --tests is running after a cargo clean (the shared target dir had picked up check-metadata stubs and reported only metadata stub found for rlib dependency std). If this lane dies, re-run that command; nothing else is outstanding.

… 0.252.92)

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d MichaelTaylor3d changed the title fix(mirror): bond-verification and funding audit residue (#527, #513, #481) fix(mirror): funding-audit residue — skip count, rival FundingObservation, alert-storm latch (#481) Sep 3, 2026
… 0.252.92)

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

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — not the verdict (loop-security audit, PR #529, head 839d1e5bc4cb72223944bc7aa2f9e4f5ce9c59c6)

Confirmed so far on the primary target (alert-latch fix, funding.rs):

  1. The new key is genuinely free of attacker-movable input. UnmeasuredFunding::alert_key() (funding.rs:700-708) drops attempted/skipped entirely for AuthenticationTruncated (key = (discriminant, None)), and keeps need_dig_base_units for NoCreateAffordable. I traced attempted's producer at funding.rs:511-524,552-557 (select_operator_dig_cats_detailed): the CandidatesUnverifiable{attempted, ..} error is constructed ONLY when the loop breaks via attempts >= MAX_AUTHENTICATION_ATTEMPTS (walked_whole_pool = false), so attempted is always exactly MAX_AUTHENTICATION_ATTEMPTS (128) whenever this variant is ever produced — it is a constant in practice, carries zero information, and dropping it loses nothing. skipped is the only field that varies, and it is 100% attacker-movable (dust coins at the operator's publicly-derivable $DIG address). So the key change is sound, not merely asserted.

  2. The guard is NOT vacuous — traced the live production path end to end. select_operator_dig_cats_detailed (called from lifecycle.rs:454 via the PR's own _detailed switch) → Err(FundingError::CandidatesUnverifiable)lifecycle.rs:527-533 funding_refusal() wraps it unchanged into PassError::Funding(error)runner.rs:443-446 (production, not test) converts it via FundingObservation::from_error(cause)FundingAlertGate::observe at funding.rs:884-206. Every hop preserves the real error. This confirms the original 144-alerts/day-off-a-one-time-dust-spend bug was real and exploitable by a stranger who simply pays into the operator's public $DIG CAT address, and that the fix's guard is exercised by real code, not dead weight.

  3. No collision that suppresses a genuinely distinct condition. NoCreateAffordable's latch behavior is unchanged (previously compared by full-struct equality on its one field; now compared via Some(need_dig_base_units) — identical effective behavior). AuthenticationTruncated collapses to one key regardless of counts, which is correct because the only two fields it carries are (constant, attacker-movable) — there is no third, legitimate axis of variation being erased. A discriminant change (Truncated ↔ NoCreateAffordable) always re-alerts (new test a_different_unmeasured_kind_still_alerts confirms both directions). I looked for a legitimate (non-attacker) scenario where skipped changing should re-alert and found none — the alert body (unmeasured_alert(*reason)) still uses the live, undropped values for its FIRST-transition message, so an operator who IS told still gets the real counts; only the decision to speak AGAIN ignores them. This is consistent with the pre-existing deficit-growth latch design elsewhere in the same gate (MATERIAL_DEFICIT_GROWTH_PERCENT), not a new invention.

  4. Regression test is at the decision point. a_truncation_whose_skip_count_moved_does_not_re_alert (funding.rs tests) calls FundingAlertGate::observe directly (the actual decision fn) twice with only skipped changed (40→41) and asserts the second call returns None. Verified by hand that this assertion would FAIL under the pre-fix full-value-equality comparison (40 != 41 → not latched → would incorrectly re-alert) — genuine red→green, not a test that passes regardless.

Independent cargo test -p dig-node-service --lib mirror::funding:: run is in progress on a fresh clone (build was blocked once by a Windows MAX_PATH issue building from a deep scratchpad path — unrelated to this PR — retried from C:\tmp\worktrees). Will report actual pass/fail counts, not just the PR's claimed 21/21, before the verdict.

Still to check: skip_report/select_operator_dig_cats_detailed double-logging on the funded/Ok path (advisory candidate, not yet confirmed severity), the lifecycle.rs/server.rs diffs in full, and the mirror_operator_funding.rs integration test additions.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

PASS
Head audited: 839d1e5bc4cb72223944bc7aa2f9e4f5ce9c59c6 (resolved via gh pr view 529 --json headRefOid, matches the dispatch brief)

Primary target -- the alert-latch fix (funding.rs, commit 546593bc)

1. The new key is genuinely free of attacker-movable input -- verified by tracing the producer, not by reading the doc comment.
UnmeasuredFunding::alert_key() (funding.rs:700-708) keys AuthenticationTruncated on (discriminant, None) -- it drops attempted AND skipped. I traced attempted's only producer, select_operator_dig_cats_detailed (funding.rs:511-524, 552-557): the loop increments attempts and breaks with walked_whole_pool = false only when attempts >= MAX_AUTHENTICATION_ATTEMPTS, and CandidatesUnverifiable{attempted: attempts, ..} is constructed ONLY on that branch. So attempted is always exactly MAX_AUTHENTICATION_ATTEMPTS (128) whenever this variant is ever produced -- a constant, carrying zero information -- and skipped (the only field that actually varies) is 100 percent attacker-movable via dust paid into the operator's publicly-derivable DIG address. Dropping both is correct; nothing legitimate is lost.

2. No collision that suppresses a genuinely distinct condition.
NoCreateAffordable's latch is unchanged in effect (Some(need_dig_base_units) now vs. full-struct equality on its one field before -- identical behavior; need_dig_base_units is plan-derived, never attacker-movable, confirmed unchanged from origin/main). AuthenticationTruncated collapsing to one key is safe because its only two fields are (constant, attacker-movable) -- there is no third legitimate axis being erased. A discriminant change always re-alerts in both directions (test a_different_unmeasured_kind_still_alerts). The alert BODY (unmeasured_alert, funding.rs:970-981) still renders the live attempted/skipped values on the transition that does fire -- only the decision to speak again ignores them, matching the pre-existing MATERIAL_DEFICIT_GROWTH_PERCENT design already used elsewhere in the same gate. Direction of failure if this were wrong would be under-alerting; I found no such case.

3. NOT vacuous -- traced the full production call chain, attacker-input to gate decision.
lifecycle.rs:459-465 (select_operator_dig_cats_detailed, wired into production by this same PR) leads to Err(CandidatesUnverifiable) then lifecycle.rs:527-533 (funding_refusal) wraps the raw FundingError unchanged into PassError::Funding then runner.rs:443-446 (production code, not test) converts it via FundingObservation::from_error into FundingAlertGate::observe (funding.rs:884-906). The gate itself is a real per-node singleton carried across pass rounds via PassRunner::with_funding_gate/take_funding_gate (runner.rs:256-279, also independently instantiated and threaded the same way in server.rs:2780,2920-2933 -- one mechanism, not two rival gates). This confirms the original 144-alerts-per-day-off-one-dust-spend bug was real and exploitable by any stranger who can pay into the operator's public DIG CAT address, and that the fix's guard is exercised by live code.

4. Regression test is at the decision point and is a genuine red-to-green.
a_truncation_whose_skip_count_moved_does_not_re_alert calls FundingAlertGate::observe directly twice, varying only skipped (40 to 41), and asserts the second call is None. Hand-verified against the pre-fix comparison (self.unmeasured == Some(*reason), full-struct equality): 40 != 41 would compare unequal, so the old code would have returned Some(...) (re-alerted) -- the assertion genuinely fails under the defect. Test count sanity-checked: 21 #[test] functions present in funding.rs at PR head, matching the PR's claimed 21 passed, 0 failed.

Independent build/test run: attempted, inconclusive within the audit window, not load-bearing. I cloned the PR head into an isolated tree (first hit a Windows MAX_PATH failure building libz-sys from the deep scratchpad path -- an environment artifact, not a code defect -- then retried from C:\tmp\worktrees\dign529, my own throwaway clone, never a shared lane checkout). The cold full-workspace compile was still working through dig-wallet (a large crate pulling in a cranelift/wasmtime chain) after several minutes and did not finish before I closed out this audit. I did not rely on it -- the hand-trace above independently re-derives the same facts the PR's own test run reports, including confirming the pre-fix assertion would have failed, which is stronger evidence than a reported pass/fail count alone.

dig-node#481 item 2 -- discarded skip count (funding.rs:334-378, lifecycle.rs:452-479, commit 5a71fbe1)

Confirmed the fix reaches production, not just tests: lifecycle.rs's only production caller now uses select_operator_dig_cats_detailed and consumes selection.skipped via skip_report. Grepped every remaining call site of the old select_operator_dig_cats wrapper -- all are test-only (unit tests in funding.rs, and the two integration test files); no production rival left. skip_report is bounded (SKIP_SAMPLE = 8, funding.rs:15), tested at the boundary (new integration test a_funded_selection_reports_what_it_passed_over_in_one_bounded_line proves 12-planted and 40-planted report the same id count, i.e., truly O(1) in attacker-chosen volume), and correctly frames the total as a floor ("AT LEAST") rather than a reassuring exact figure -- no zero-as-unknown pattern. reason: String (per-candidate, could vary in length) is deliberately excluded from the formatted report, which is an additional, unasked-for hardening of the O(1) bound rather than a gap.

Advisory, non-gating: duplicate log line on the funded (Ok) success path. select_operator_dig_cats_detailed itself unconditionally emits one skip_report warn (funding.rs:545-547) before it can even return Ok, and lifecycle.rs:470-477 emits a second, near-identical warn (with store_id added) after receiving the Ok selection. Two lines instead of one when skips are greater than zero on a funded create. Still strictly O(1) per selection (not attacker-scalable beyond the existing per-selection bound), so this is a log-noise/DRY nit, not a security or DoS finding. Worth a follow-up ticket, not a gate.

dig-node#481 item 3 -- rival FundingObservation rename (wallet_funded.rs renamed to EverFundedEvidence, commit 4fa8702a)

Confirmed behavior-preserving: every variant, method, and test reference was renamed consistently in one commit; grepped for stray references to the old name outside doc comments in server.rs/pass.rs/presence.rs/runner.rs and found none -- the two doc-comment hits in pass.rs/runner.rs correctly still refer to mirror::funding::FundingAlertGate/FundingObservation, the OTHER type, which is intentionally untouched. server.rs's diff is the rename only -- no logic change (classify, should_latch, the synced-gates-only-the-zero-case semantics all byte-identical). This is a genuine disambiguating rename of two same-named-but-different concepts, not a merge that would have collapsed the distinction; correctly documented with a comparison table in the new doc comment.

Scope confirmation

MAX_AUTHENTICATION_ATTEMPTS = 128 (funding.rs:269) and its lack of a cross-bond shared budget (dig-node#481 item 1) are unchanged by this diff (no lines touched in that area besides the comment explaining why per-candidate logging was removed) -- correctly left open, and this diff does not make it worse: the alert-storm fix operates entirely downstream of that budget and does not change how many attempts a single call may make.

SPEC.md section 25.11/25.12 additions (SPEC.md:9347-9410) match the shipped code exactly, including the "MUST exclude attacker-movable fields from the comparison, MUST NOT exclude a legitimate re-raise" framing -- no doc/code drift.

Verdict summary

No live vulnerability found. The alert-latch fix is sound on all four adversarial angles requested (attacker-movability of the key, false-suppression risk, vacuity, test placement), the dig-node#481 items 2 and 3 are genuine fixes reaching production with no rival left behind, and the deliberately-deferred item 1 is correctly untouched and not worsened. One advisory (duplicate log line, non-gating) recommended as a follow-up ticket, not a merge blocker.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Independent build/test run completed after the verdict above was posted -- corroborates it.

cargo test -p dig-node-service --lib mirror::funding:: on a fresh clone of PR head 839d1e5b, built from C:\tmp\worktrees\dign529 (my own throwaway clone):

test result: ok. 21 passed; 0 failed; 0 ignored; 0 measured; 777 filtered out; finished in 0.10s

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 0 passed; N filtered out and exit 0 while proving nothing). This includes a_truncation_whose_skip_count_moved_does_not_re_alert, a_changed_collateral_requirement_alerts_again, and a_different_unmeasured_kind_still_alerts all passing, plus authentication_is_bounded_by_a_constant_however_many_coins_a_stranger_sends (a pre-existing regression test for the shared-budget property that is correctly unchanged by this diff).

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."

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 3, 2026 14:44
@MichaelTaylor3d
MichaelTaylor3d merged commit 66ba53d into main Sep 3, 2026
13 of 14 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/527-513-481-mirror-residue branch September 3, 2026 14:44
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