Skip to content

fix(mirror): skip unauthenticatable funding candidates and alert on a funding shortfall - #469

Merged
MichaelTaylor3d merged 17 commits into
mainfrom
loop/mc-fund
Sep 1, 2026
Merged

fix(mirror): skip unauthenticatable funding candidates and alert on a funding shortfall#469
MichaelTaylor3d merged 17 commits into
mainfrom
loop/mc-fund

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

DRAFT — do not merge. Gate round has not run.

Closes #461
Closes #463

Two defects on the mirror funding path.

#461 — one dust coin permanently blocks every mirror create

select_operator_dig_cats authenticated each selected candidate with ?, so the first
unauthenticatable coin aborted the whole selection. The address is publicly derivable, selection is
largest-first, so one dust coin with a large declared amount sat at the front of the order forever.

Fixed by skipping unauthenticatable candidates and continuing.

#463 — notify the operator when funds block a create

The pass runs unattended every ten minutes and refuses silently. This adds the transition-debounced
decision layer that says WHEN to alert and WHAT the remedy is.

Blast radius, tests and the notification policy are recorded in the PR thread as the work lands.

Stub commit so the lane's state is durable from its first action.

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d and others added 3 commits August 31, 2026 14:22
…efusing the create

One coin nobody can authenticate, at the publicly derivable operator $DIG address,
carrying a larger declared amount than any honest coin, was walked first by
largest-first selection and aborted the whole selection. Cost to the attacker:
dust. Cost to the operator: no mirror coin could ever be created.

A skipped candidate is counted, reported and logged at warn, and leaves the
candidate POOL rather than occupying a selection input slot -- so dust cannot
reinstate the denial by volume either.

Refs #461

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

The mirror pass runs unattended every ten minutes and refused a create for want
of funds silently, forever -- the operator's content stops being bonded while
every surface still looks healthy.

Policy, stated in the code because how often it fires is the design: alert on
the transition into the short state; while short, again only if the remedy
changes or the deficit grows 50%; once on recovery; never on an unreadable
balance, which also does not count as recovery.

Reclaims are NOT covered: lifecycle.rs builds every reclaim at fee = 0 with no
fee coins, unconditionally, so a reclaim cannot fail for want of funds.

Refs #463

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

Copy link
Copy Markdown
Contributor Author

Both fixes are written, committed and pushed. Verification is BLOCKED on host disk (os error 112 / LNK1318 FILE_SYSTEM), not on the work.

Branch loop/mc-fund, head 0ff6147, draft PR #469.

What is proven and what is not

  • Compiles. cargo test -p dig-node-service --lib mirror::funding drove both dig-node-service
    (lib) and its lib-test target through rustc successfully; the run died at the LINK step with
    LNK1318: Unexpected PDB error; FILE_SYSTEM (3) — the linker could not write the PDB because the
    volume was full. So the library and its unit tests type-check.
  • NOT proven: a green run. No test in this branch has executed. Free space on C: fell from
    12 GB to 2.4 GB during this lane while sibling lanes built, and a follow-up
    cargo check --test mirror_operator_funding failed even earlier, in the aws-lc-sys and
    libz-sys build scripts. Per CLAUDE.md §1.6 this lane does not reclaim space it does not own —
    its own target/ is 6.6 GB and is the only cache it may take.

Nothing here should be read as a green. The tests below are written and unrun.

#461 — the skip

select_operator_dig_cats_detailed authenticates against the candidate POOL. A candidate that fails
authentication is counted into FundingSelection::skipped, logged at warn with its coin id and
reason, removed from the pool, and the requirement is covered again from what remains. Each
candidate is authenticated at most once, whatever the pool is re-covered. select_operator_dig_cats
survives unchanged as the Vec<Cat> half, so lifecycle.rs:429 is untouched.

A skip therefore cannot consume a selection input slot: the returned set contains authenticated
coins only, and its size is a function of the honest coin set alone.

A chain that cannot ANSWER stays fatal. An unreadable source is not a verdict about a coin.

Insufficient { have } now reports the total of the pool AFTER removals — the honest spendable
total — rather than everything sitting at the address. Reporting the latter would tell an operator
their wallet holds money that is not theirs.

#463 — the alert gate

FundingAlertGate::observe(&FundingObservation) -> Option<FundingAlert>: pure, no clock, one pass
of state, and not the delivery mechanism. FundingObservation::from_error classifies a
FundingError with an exhaustive match, so the bounded-input variant in flight on PR #457
cannot compile until someone decides whether it is a shortfall and which remedy it names.

Blast radius

impact was unavailable (gitnexus MCP tools are not in this lane's tool set), so the radius was
taken by grep and read directly, and it is small: select_operator_dig_cats has exactly two
callers — mirror/lifecycle.rs:429 and tests/mirror_operator_funding.rs — and its signature and
semantics are unchanged for both except that a previously-fatal candidate is now skipped.
FundingError gained no variant. Nothing outside mirror/funding.rs was edited.

Wiring still owed, and it is NOT mine to make

The gate decides; nothing delivers yet. mirror/runner.rs belongs to the #464 lane, so the call it
needs is reported rather than made: the runner holds the FundingAlertGate beside its existing
cross-round presence state (runner.rs:243-245), feeds it FundingObservation::from_error(&e) on a
create refusal and FundingObservation::Healthy on a funded pass, and hands any returned
FundingAlert to the delivery path.

There is no OS-notification mechanism in dig-node today. The updater surfaces its messages over
the control.* RPC surface, which dig-app polls (updater.rs:1, control.rs:2222) — there is no
notify-rust or equivalent anywhere in the tree. So "reuse the existing path" resolves to the
control surface, and a genuine desktop toast is dig-app's half. That is a real gap in #463's
premise and is recorded here rather than worked around.

MichaelTaylor3d and others added 2 commits August 31, 2026 16:05
Reconciles dig-node#427's input bound (MAX_SELECTED_FUNDING_COINS, from #457)
with dig-node#461's skip-instead-of-abort selection.

- The bound is applied to the CURRENT selection inside the pool loop, which by
  construction holds no candidate already proven unauthenticatable. A skipped
  coin therefore costs no input slot, so an attacker cannot reinstate #461 in a
  slower form by dusting the address until the bound alone refuses every create.
- `FundingError::TooManyInputs` gains the amounts it already had in scope, so
  `FundingObservation::from_error` can classify it as a `Consolidate` shortfall.
  Before this it was the exhaustive match's uncovered variant, and `Consolidate`
  had no producer outside tests.
- Main's bound test asserted the pre-#461 abort semantics at the bound. Its
  at-bound half now asserts that the bound did NOT speak and that authentication
  WAS reached, which still goes red on the `>=` off-by-one.

Version 0.201.0, free above main's 0.198.0 and unused by the sibling lanes.

Co-Authored-By: Claude <noreply@anthropic.com>
…ate across runners

dig-node#463's gate was a library nobody called. Its nine unit tests each drive
ONE gate over many observations, so all nine stay green against a node that
builds a fresh gate every pass and notifies 144 times a day.

- `PassError::Funding(FundingError)` carries the refusal structurally. It was
  flattened to a string in `funding_refusal`, so the only surface that can tell
  an operator what to DO would have had to recover the classification from prose.
  Display delegates, so every consumer that only renders a PassError is unchanged.
- `PassRunner` holds the gate beside the presence tracker, and the scheduler
  carries both across the runners it rebuilds each round.
- `PassReport::funding_alert` returns the message; the runner also logs it at
  warn, so it reaches stderr on a node that cannot write its state dir (#440).
- A create that stopped for a NON-funding reason maps to `Unknown`, so an
  unrelated failure cannot be announced to an operator as a recovery.

Two tests cover the wiring itself: the alert fires once across rebuilt runners
and never from a stringly refusal, and an unrelated failure neither alerts nor
clears a live shortfall.

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

Copy link
Copy Markdown
Contributor Author

Verification record — the lane that wrote this never ran a test; it has now been run

Head fde43d3. Still DRAFT.

Merged main (3ce4785), one real conflict

#457 added MAX_SELECTED_FUNDING_COINS to the same function #461 restructured.

  • The bound is now applied to the current selection inside the pool loop. That set holds no candidate already proven unauthenticatable — those left the pool — so a skipped coin costs no input slot, and dusting the address cannot reinstate One dust coin at the public CAT address permanently blocks every mirror create #461 in a slower form via the bound alone.
  • FundingError::TooManyInputs was the exhaustive match's uncovered variant and would not compile. It is classified as a Consolidate shortfall, which also gives FundingRemedy::Consolidate its first producer outside tests — before this, shortfall_alert's consolidate branch could never be shown to an operator. The variant carries the two amounts it already had in scope so the message can state them.
  • Test changed, deliberately: main's a_create_needing_more_inputs_than_the_bound_is_refused_before_any_lineage_read asserted the pre-One dust coin at the public CAT address permanently blocks every mirror create #461 abort semantics at the bound (Err(Unauthenticated) after exactly one lineage read). Under skip semantics that fixture skips the whole pool and ends short. The at-bound half now asserts the bound did not speak and that authentication was reached — still red on the >= off-by-one, which is the property it existed for.

Runner wiring — #463 was a library nobody called

Its nine unit tests each drive one gate over many observations, so all nine stay green against a node that builds a fresh gate per pass and notifies 144 times a day.

  • PassError::Funding(FundingError) carries the refusal structurally. funding_refusal flattened it to a string, so the classification would have had to be recovered by matching on prose.
  • PassRunner holds the gate beside the presence tracker; the scheduler carries both across the runners it rebuilds each round.
  • PassReport::funding_alert returns it; the runner also logs at warn so it reaches stderr on a node that cannot write its state dir (a failed mirror create is silent — stopped_at carries the cause and is never logged #440).
  • A create stopped for a non-funding reason maps to Unknown, so an unrelated failure is never announced as a recovery.

Results (counts, not exit status)

run result
--lib mirror::funding 15 passed, 0 failed (699 filtered)
--test mirror_operator_funding 11 passed, 0 failed, 0 filtered
--lib mirror:: 133 passed, 0 failed
cargo test -p dig-node-service --locked (whole crate) 716 lib + all integration, 0 failed, RC=0
cargo clippy --all-targets -- -D warnings RC=0
cargo fmt --all -- --check RC=0

Revert-proofs — on a committed tree, restored after each

fix reverted tests that went red reason
#461 skip arm disabled an_unauthenticatable_candidate_is_skipped_…, many_unauthenticatable_candidates_… (9 passed / 2 failed) both Err(Unauthenticated { reason: "its creating spend is not on chain" }) — the whole selection refused, i.e. the DoS
#463 dedup removed + Unknown clears consecutive_short_passes_…, a_recovery_then_a_second_shortfall_…, an_unknown_funding_state_…, the_deficit_must_grow_materially_… (11 passed / 4 failed) 10 alerts where 1 was required; shortfall re-announced after an unreadable pass
gate carry dropped from with_funding_gate a_funding_shortfall_alerts_once_across_rebuilt_runners_…, a_non_funding_create_failure_neither_alerts_… (17 passed / 2 failed) second consecutive short pass alerted again

Notes

  • Version 0.201.0 — free above main's 0.198.0 and unused by the five sibling lanes (0.200/0.202–0.205). Cargo.lock carries it; --locked passes.
  • The first two full-suite runs failed in target/ with invalid metadata / required to be available in rlib format / cannot find type Option. Not code — leftover artifacts from the lane's ENOSPC death plus a parallel-build race. rm -rf target and -j 4 cleared it; the toolchain probe and registry sources were verified intact.
  • target/ is left in place for the gate round rather than deleted per §1.6, since a cold rebuild here is ~15 minutes. 123 GB free.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Auditing at head fde43d3d3a652c37dba6f6648375cc83daf202b2 (resolved from gh pr view 469 --json headRefOid), merge-base 3ce4785b2abe39235ac7d6a5850e43a99ccff6d4. Own worktree at C:/tmp/worktrees/sec469; no shared checkout touched.

Two findings established from the code so far. Posting now rather than at the end. Probe build running.


A. The #461 fix trades a fixed-cost DoS for an UNBOUNDED-work one — no limiter runs before the expensive step

crates/dig-node-service/src/mirror/funding.rs:314-317 scans with coin_records_by_puzzle_hash(dig_cat_puzzle_hash(owner), false). That call takes no limit, so the pool size N is whatever the attacker put at the public address.

The new loop at funding.rs:332-374 then:

  • clones and re-sorts the whole pool once per rejected candidateselect_largest_first(pool.clone(), ...) at :338, and select_largest_first (dig-wallet sage/selection.rs:64-71) decorates and sort_bys the entire vector every call;
  • removes exactly one candidate per iteration — the break at :361 plus pool.retain(...) at :371;
  • authenticates via authenticate at :342, which is a network round tripsource.coin_spend(record.coin.parent_coin_info) at funding.rs:653, against the peer-pool ChainSource built at server.rs:2825.

So for N unauthenticatable candidates each declaring an amount larger than the operator's honest coins (which is what puts them first under largest-first): N iterations, N sequential chain RPCs, and O(N^2 log N) CPU, repeated every mirror pass — the pass timer, and the pass body runs under tokio::task::block_in_place (server.rs:2845), so the round holds a worker and the next pass cannot start until it finishes.

MAX_SELECTED_FUNDING_COINS = 32 (funding.rs:179) does not bound this. It bounds selected.len() inside an iteration; it bounds neither the iteration count nor the authentication count. And in the shape that matters it never fires at all: if each planted coin alone covers the requirement, selected.len() == 1 every iteration.

The bound's own claim at funding.rs:349-352 — that a skipped coin costs no input slot — is true as written, and I verified it by construction: a rejected candidate leaves the pool at :371 before the requirement is re-covered, so it can never occupy a slot in a later selected. The brief asked me to check that specific composition and it holds. The amplification is a different channel that the bound was never positioned to cover.

Severity depends on the cost of planting N coins, which I am still pricing. Ranking in the verdict.


B. Insufficient { have } reports the stranger's money as the operator's — and #463 now puts that number in front of a person

funding.rs:333-336 computes available from the pool at the top of the iteration, i.e. before any candidate in that iteration has been authenticated. The comment at :341-345 claims:

"The honest total: every candidate proven unauthenticatable has already left the pool, so this is what the operator can actually spend rather than what the address happens to hold."

That is false whenever the shortfall is detected before an authentication happens, which is the ordinary case: select_largest_first fails as soon as the pool total is under the requirement, and on the first iteration nothing has been removed. have is then the sum of every unspent, uncommitted coin at the public address — exactly "what the address happens to hold", including a stranger's.

This was pre-existing arithmetic, but this PR changes what it is: FundingObservation::from_error (funding.rs:452-462) reads that field, and shortfall_alert (funding.rs:591-601) renders it to an operator as "the operator wallet holds {} DIG that it can spend, so it is {} DIG short." An attacker who plants coins at the address totalling X makes the node understate the deficit by X.

The follow-on is worse than the wrong number. An operator who tops up by the amount they were told is still short, and grew_materially (funding.rs:578-582) suppresses the re-alert whenever the residual deficit lands inside 50% of the one already reported — so the node goes quiet and stops bonding content with the operator believing they fixed it.

Writing a probe to nail B as an executable fact rather than a reading.

(This is an interim note. The verdict follows.)

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (2/2)

Head fde43d3d3a652c37dba6f6648375cc83daf202b2. Third finding, and it is the one I would gate on.


C. The 32-input bound returns BEFORE authentication — so an attacker chooses which remedy the operator is told to perform (HIGH)

The brief asked me to confirm FundingRemedy::Consolidate is "genuinely reachable and correctly distinguished from a top-up shortfall". It is now reachable. It is not correctly distinguished: an attacker selects it.

funding.rs:353-360:

if selected.len() > MAX_SELECTED_FUNDING_COINS {
    return Err(FundingError::TooManyInputs { needed: selected.len(), limit: ..., have_dig_base_units: available, ... });
}

Authentication does not begin until :364. So selected here may be composed entirely of coins nobody has checked, and the return means none of them is ever checked, ever removed from the pool, or ever counted in skipped.

The comment at :349-352 defends the bound like this:

"applied to the CURRENT selection, which by construction contains no candidate already proven unauthenticatable — those left the pool. So a skipped coin costs no input slot, and an attacker cannot reinstate dig-node#461 in a slower form."

The first clause is true and I verified it. The conclusion does not follow, because the class that matters is candidates not yet proven — and those are exactly what an attacker supplies. A coin costs an input slot for as long as it has not been disproven, and on this path it is never disproven.

The exploit. State: an operator whose honest holdings are below the epoch requirement — a node that is short, or a fresh node that has not funded yet. This is precisely the population #463 exists to serve.

  1. The attacker derives dig_cat_puzzle_hash(owner) from the operator's public owner puzzle hash (the One dust coin at the public CAT address permanently blocks every mirror create #461 premise, unchanged) and reads the epoch requirement need.
  2. They pay 33 or more coins to that address, each small enough that covering need takes more than 32 of them. Total cost is bounded by need plus fees; denominations can be made arbitrarily small, so the count is robust to whatever honest coins are present.
  3. Every pass: selected.len() > 32 at :353 → immediate TooManyInputs, zero chain reads, pool unchanged.
  4. FundingObservation::from_error (funding.rs:456-462) maps TooManyInputsShort { remedy: Consolidate }.
  5. shortfall_alert (funding.rs:596-601) renders: "the operator wallet holds enough $DIG for the N DIG this epoch requires, but in too many separate coins to spend at once. Consolidate the wallet's $DIG into fewer coins — adding more will not help."

The operator may hold zero $DIG and be told they hold enough, and be told the one action that would fix it will not help. That is the money-lie class the contract says still stops a merge.

Two things make it worse rather than transient:

  • It is permanent. The early return performs no authentication, so the planted coins never leave the pool. Unlike the skip path, this does not converge — it repeats identically on every pass.
  • It is announced once and then silenced. For Consolidate, have >= need, so deficit == 0; grew_materially(0, 0) is false forever (funding.rs:578-582). The operator gets exactly one notification, and it is the wrong one. The correct TopUp alert they were owed is never raised, because the classification was hijacked upstream of the gate.

from_error's own comment at :449-451"the operator is not missing money, and an alert that quoted a deficit would send them to buy $DIG they already hold" — is false under this input. They are missing money, and they are being told not to buy any.

What I checked that came out CLEAN, so the fix is not "revert the bound": a funded operator cannot be pushed over the bound. select_largest_first sorts by amount descending and stops at the first coin where the running total reaches the target, so any inserted coin that sorts inside the selected prefix has an amount at least equal to the coin it displaced and therefore cannot increase the number of coins required. The bound's resistance to dusting holds for the case the comment was really written about. The defect is confined to the branch where the honest holdings do not cover the requirement.

Root cause shared with finding B: available (funding.rs:333-335) and the bound (:353) are both computed over unauthenticated candidates, and both figures then flow to an operator-facing money statement. The classification decision needs to be made from authenticated coins, or the bound needs to fall through to authentication rather than returning.

(Interim. Probes building; verdict to follow.)

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security: CHANGES-REQUIRED

Head audited: fde43d3d3a652c37dba6f6648375cc83daf202b2 (resolved from gh pr view 469 --json headRefOid; unchanged for the whole audit). Merge-base 3ce4785b2abe39235ac7d6a5850e43a99ccff6d4. Own worktree C:/tmp/worktrees/sec469, own target dir, own scratch. No shared checkout touched; dn-fund was read for its HEAD only and never written.

Four LIVE findings. Three are proven by executable probes (3 passed; 0 failed — count checked, not exit status). The probes were written in my own worktree, are not committed, and the worktree is destroyed with this report.

The #461 fix is structurally right, and the specific composition I was asked to break holds (see CLEAN, below). What is wrong is that the two figures the fix hands to #463available and selected.len() — are computed over candidates nobody has authenticated, and #463 turns both into money statements shown to an operator.


1. HIGH — an attacker chooses the remedy; an operator holding ZERO $DIG is told they hold enough and that adding more will not help

crates/dig-node-service/src/mirror/funding.rs:353-360 returns TooManyInputs before authentication begins at :364. So selected may be composed entirely of unchecked coins, and the return means none is ever checked, ever removed from the pool, or ever counted in skipped.

The defence at :349-352 says the selection "by construction contains no candidate already proven unauthenticatable". That is true, and I verified it. It does not follow, because the class that matters is candidates not yet proven — precisely what an attacker supplies.

Exploit. An operator whose honest holdings are under the epoch requirement (short, or a fresh node) — the population #463 exists for. The attacker derives dig_cat_puzzle_hash(owner) from the public owner puzzle hash and pays 33 or more coins to it, denominated small enough that covering need takes more than 32. Cost is bounded by need plus fees, and the denomination is free to choose, so the count is robust to whatever honest coins are present.

Probe C, executed, verbatim output — the operator holds nothing:

PROBE C ALERT BODY: Your node cannot bond content: the operator wallet holds enough $DIG
for the 40.000 DIG this epoch requires, but in too many separate coins to spend at once.
Consolidate the wallet DIG into fewer coins - adding more will not help.

with chain.reads == 0 asserted: no coin was authenticated, so no planted coin can ever leave the pool. Unlike the skip path, this does not converge — it repeats identically on every pass, forever.

It is also announced once and then silenced: for Consolidate, have >= need so deficit == 0, and grew_materially(0, 0) is false forever (funding.rs:578-582). The operator receives exactly one notification, it is the wrong one, and the TopUp alert they were owed is never raised, because the classification was hijacked upstream of the gate.

The from_error comment at :449-451 — that the operator "is not missing money, and an alert that quoted a deficit would send them to buy $DIG they already hold" — is false under this input.

Answering the brief directly: FundingRemedy::Consolidate is now genuinely reachable in production, but it is not correctly distinguished from a top-up shortfall. An attacker picks which of two opposite instructions the operator is given.


2. HIGH — the ordinary shortfall is classified Healthy, which makes #463 vacuous for its main case and produces a FALSE RECOVERY

runner.rs:445-451 maps "nothing stopped" to FundingObservation::Healthy, reasoning that this includes "a pass that planned none, which is the healthy state a node with nothing new to bond sits in."

"Planned none" has two causes, and they are opposites:

Consequences:

  • The Short alert has no producer for the ordinary empty-wallet case. A node that cannot afford a single create reports Healthy on every pass. The only routes into PassError::Funding are a disagreement between the balance oracle (rpc.rs:1399 balance_for_address) and the chain scan, or finding 1.
  • It CLEARS a live shortfall and raises a false recovery: "The operator wallet can fund mirror collateral again. Your content is being bonded on the next pass." Neither clause is true. Reachable with no attacker at all: coins committed to an in-flight bundle are excluded from selection (funding.rs:322) but counted by the balance oracle, so pass N can alert Short and pass N+1 can read a balance under per_coin and announce recovery.

This is the same failure the Some(_) => Unknown arm at :441-444 was written to prevent, entered by the next door: a pass that never asked is not evidence about the wallet.

The fix is in hand and discarded. bond_states already emits BondState::Unfunded { short_dig_base_units } (pass.rs:78-82) for exactly this case, and states is destructured two lines above the classification at runner.rs:380-385. The observation simply does not read it.

This one also belongs to the correctness gate — it is the deeper form of the vacuity the verification pass already found once on this PR.


3. HIGH — no limiter runs before the expensive step: one chain round trip per attacker-planted coin, every pass

funding.rs:314-317 scans with coin_records_by_puzzle_hash(...), which takes no limit, so the pool size N is attacker-chosen. The loop at :332-374 then clones and re-sorts the whole pool once per rejection (select_largest_first(pool.clone(), ..) at :337; dig-wallet sage/selection.rs:64-71 sorts on every call), removes exactly one candidate per iteration (:361 plus :371), and authenticates via a network round trip (funding.rs:653, source.coin_spend) against the peer-pool source built at server.rs:2825.

Probe A, executed — linear, one for one, no bound:

PROBE A: planted=10  chain_reads=11  skipped=10
PROBE A: planted=50  chain_reads=51  skipped=50
PROBE A: planted=200 chain_reads=201 skipped=200

So N planted coins cost N sequential chain RPCs and O(N^2 log N) CPU per pass, plus N WARN lines (funding.rs:376-386). The pass body runs under tokio::task::block_in_place (server.rs:2845), so the round holds a worker and the next pass cannot begin until it finishes. To be walked at all, planted coins must out-declare the honest ones, so the attacker cost is N times A mojos — cheap at any N that matters.

MAX_SELECTED_FUNDING_COINS = 32 does not bound this. It bounds selected.len() within an iteration, not the iteration count and not the authentication count; and in the shape that matters it never fires at all, because a planted coin that alone covers the requirement gives selected.len() == 1 every time.

Before this PR the same scan did one sort and stopped at the first failed authentication. The amplification is introduced here.


4. MEDIUM — Insufficient { have } reports the address total, so the deficit is understated and the correction is then suppressed

funding.rs:333-335 computes available from the pool at the top of the iteration, before anything in that iteration is authenticated. The comment at :341-344 claims it is "the honest total ... rather than what the address happens to hold". That is false whenever the shortfall is detected before an authentication happens — the ordinary case, and always the case on the first iteration.

Probe B, executed — the operator can genuinely spend 20.000 DIG:

PROBE B ALERT BODY: ... it needs 40.000 DIG of collateral for this epoch and the operator
wallet holds 30.000 DIG that it can spend, so it is 10.000 DIG short. ...

with chain.reads == 0 asserted. They are 20.000 short and are told 10.000.

The follow-on is worse than the wrong number. An operator who adds the amount they were told is still short, and grew_materially (:578-582) suppresses the re-alert whenever the residual deficit lands inside 50% of the one already reported. The node then goes quiet and stops bonding while the operator believes they fixed it.


Root cause, stated once

Findings 1, 3 and 4 are one defect: available and selected.len() are computed over unauthenticated candidates, and both now flow to an operator-facing money statement. Any fix that makes the classification a function of authenticated coins only, and bounds the candidate set before the authentication loop, closes all three. Finding 2 is separate, and is fixed by reading BondState::Unfunded instead of treating an empty create list as healthy.


What I checked that is CLEAN

  • The composition I was asked to break holds. A candidate proven unauthenticatable cannot occupy an input slot: it leaves the pool at :371 before the requirement is re-covered. Verified by construction, not from the comment.
  • A funded operator cannot be pushed over the 32-input bound. select_largest_first sorts by amount descending and stops once the running total reaches the target, so any inserted coin that sorts inside the selected prefix has an amount at least equal to the one it displaces and cannot raise the count. Finding 1 is confined to the branch where honest holdings do not cover the requirement.
  • No classification is recovered from prose anywhere in production. The only contains(..) calls on alert text are test assertions (funding.rs:713,801). PassError::Funding carrying the error whole is behaviour-safe: no production code matches on PassError::Chain versus Wallet — they are Display-only.
  • an_alert_never_carries_a_coin_id_or_an_address holds for every alert this crate can construct, not only the fixtures. There are exactly two producers — shortfall_alert and the recovery literal — and every interpolation goes through whole_dig (funding.rs:606-608), which always emits a ., so no token can be a run of 16 or more hex digits. Both probe bodies above are identifier-free.
  • The gate genuinely carries across runners, including on the pass-error path: server.rs:2870-2879 takes the gate by &mut before into_presence consumes the runner, unconditionally. The runner test discriminates the mechanism correctly.
  • Secrets: none. No key, token, credential or projectId introduced, logged or committed.
  • Dependencies: none added. The only Cargo.lock change is the version line.
  • No unsafe, and no new panic surface on the production path. Every added expect/unwrap is under #[cfg(test)]. Arithmetic is saturating_* and checked_div throughout, which matters because this workspace keeps overflow checks on in release.
  • Version 0.201.0 verified on disk in Cargo.toml:35 and Cargo.lock:3034, against the 0.198.0 on origin/main.

Defense-in-depth — named, NOT gated

  • Log amplification and attacker-controlled log content. funding.rs:376-386 emits one WARN per skipped candidate per pass carrying an attacker-chosen coin_id, and reason embeds a driver error string (:667). Ticket it alongside the finding-3 bound.
  • PassReport::funding_alert has no production consumer. publish and log_mirror_pass never read it, so the alert reaches a person only as the tracing::warn! at runner.rs:461. The desktop-notification and lock-screen rationale is currently hypothetical — worth stating, so the identifier-hygiene rule is not later relaxed on the belief that nothing renders it.
  • The server-side gate wiring is untested. The runner test passes with_funding_gate explicitly, so deleting the call at server.rs:2879 would not go red. Same shape as the presence tracker, so consistent rather than novel.

What I could not reach

  • Whether a real Sage or replica balance read counts the planted coins. balance_for_address (rpc.rs:1399) routes between the local replica and a third-party oracle, and I did not establish whether either validates CAT lineage before counting a coin at the DIG CAT puzzle hash. This affects only which of findings 1 and 2 fires first: if planted coins are counted, the affordability check passes and finding 1 fires; if not, finding 2 fires and the node reports Healthy. Both are defects, so the verdict does not depend on resolving it — but the fix for finding 1 must not assume the balance oracle filters.
  • No live-node or on-chain reproduction. Everything above is the unit and integration seam plus static reading.
  • .gitnexus was not used. It returns the documented false-safe impactedCount: 0 on this repo, so blast radius was done by grep plus direct read, with match counts checked rather than sampled.

Verdict

CHANGES-REQUIRED. Findings 1 to 4 are live and operator-facing; 1, 3 and 4 share one root cause and one fix. Re-gate scope: 1, 3 and 4 are loop-security; finding 2 should also go to the correctness gate, since it is the deeper form of the vacuity already found once on this PR.

My worktree, probe file and scratch are removed. Nothing of this audit remains on disk.

…se the operator's remedy

Both figures the funding selection reports to an operator -- the total they can
spend, and whether their money is merely in too many pieces -- were computed over
candidates nobody had authenticated, at a puzzle hash anyone can derive. So a
stranger who paid 33 small coins into it made an operator holding zero $DIG read
'the operator wallet holds enough $DIG ... adding more will not help', on every
pass, forever: no planted coin was ever authenticated, so none was ever removed.

Authentication now runs FIRST and the input bound is applied to what survives it.
That moves the cost, so the cost is bounded in its own right by a constant,
MAX_AUTHENTICATION_ATTEMPTS -- previously one chain round trip per planted coin,
per pass, measured linear at 11/51/201 reads for 10/50/200 coins.

A truncated walk refuses as CandidatesUnverifiable and states no total, because
it does not have one; an understated total sends an operator to buy $DIG they
already hold and grew_materially then suppresses the correction.

Refs dig-node#469
…ealthy node

A pass reaches PassError::Funding only when a create was ATTEMPTED and refused.
A wallet holding less than one create's collateral never attempts one: decide
hands execute the affordable prefix, and an empty prefix does not iterate. So
nothing stopped, and the pass classified itself Healthy -- leaving dig-node#463's
Short alert with no producer for the commonest real shortfall there is, and
CLEARING a live one with a false 'collateral resumed / your content is being
bonded'. Neither clause is true, and it needs no attacker: coins committed to an
in-flight bundle are withheld from selection but counted by the balance oracle.

The funds split already knew. decide now carries the shortfall it computed --
the leftover $DIG against the cost of the creates it could not make, so the
deficit is the money that must actually be ADDED -- and the classification reads
it instead of inferring health from an empty create loop.

Refs dig-node#469
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

CHANGES-REQUIRED addressed — all four findings, head 8ead74e

Version 0.207.0 (origin/main moved to 0.206.0 during this work; merged, and the version re-read from Cargo.toml on disk after the merge rather than from the commit log).

The ordering chosen, and why

Authenticate → bound the authentication → bound the inputs → report.

Findings 1, 3 and 4 are one defect, as the audit says: available and selected.len() were computed over rows at a publicly derivable puzzle hash, and both became money statements. Authentication is what turns a row into one of this operator's coins, so it has to run before any figure is computed from those rows — a bound applied earlier is a bound an attacker sets, and the refusal it produces (Consolidate) is a sentence the same attacker chooses.

That ordering moves the cost, so the cost is bounded in its own right: MAX_AUTHENTICATION_ATTEMPTS = 128, a constant that does not depend on how many coins a stranger sent. The audit named the alternative and it was rejected deliberately — a cheap pre-filter would have bounded the work while leaving both figures attacker-chosen, which fixes 3 and leaves 1 and 4 exactly as they were.

Two consequences worth stating rather than burying:

  • The walk stops the moment the authenticated total covers the requirement, so a healthy pass pays for the coins it spends and nothing more. The constant is only reached under noise.
  • A walk truncated by the budget refuses as a new condition, CandidatesUnverifiable, which states NO total and maps to FundingObservation::Unknown — it raises no alert and clears none. It does not have a total: an amount taken from a truncated walk is understated by however many coins the budget did not reach, which is the finding-4 failure re-entered through the fix for finding 3. from_error's exhaustive match forced this decision rather than letting a wildcard classify it.

The input bound is not weakened. It is applied to a selection drawn from authenticated coins, and it still fails closed.

Residual, named not hidden: a stranger who buries the honest coins under 128 larger unauthenticatable ones stops this node bonding until they are spent or the wallet is consolidated. That is a denial of service and any constant bound has one. What they can no longer do is make the node tell its operator something false about their money.

Finding 2

decide already computed the answer and threw it away. PassDecision now carries a FundingShortfall read off the funds split — the leftover $DIG against the cost of the creates it could not make, so the deficit is the money that must actually be added — and the classification reads it instead of inferring health from an empty create loop.

The four failing-then-passing runs

Each fix was reverted alone, in a committed tree, and only its own test was run. Test counts checked, not exit status — a filter matching nothing exits 0.

# revert applied test result with the revert
1 input bound re-applied to the raw pool coins_a_stranger_planted_never_become_a_statement_about_the_operators_money 0 passed; 1 failed
2 Healthy arm restored a_wallet_that_can_afford_nothing_alerts_short_and_never_announces_a_false_recovery 0 passed; 1 failed
3 authentication budget removed authentication_is_bounded_by_a_constant_however_many_coins_a_stranger_sends 0 passed; 1 failed
4 address total reported again the_reported_total_is_what_the_chain_proved_not_what_the_address_holds 0 passed; 1 failed

The two integration probes reproduce the audit's own output verbatim under revert:

  • probe B — left: Insufficient { have_dig_base_units: 30000, need: 40000 } against the required have: 20000;
  • probe C — left: TooManyInputs { needed: 36, limit: 32, have_dig_base_units: 45780, need: 40000 } against the required Insufficient { have: 1000 }.

On the fixtures, since this is where the false greens come from

The unit fixtures can only build candidates that fail authentication — a Cat exists only once a real creating spend has been executed — so on their own they are the all-hostile fixture that cannot see a missed honest coin. Three things address that:

  • select_within_input_bound was split out so the bound and the totals are provable over authenticated coins directly, from both sides (exactly MAX_SELECTED_FUNDING_COINS must pass; one over must be refused with the count, the limit and the real total). Driving the bound through candidates that fail authentication could only ever prove the defect.
  • Two integration tests put genuine CAT lineage beside the planted coins and vary only the stranger. The operator holds a real 1.000 DIG in the probe-C fixture, so the assertion distinguishes authenticated coins are counted from everything is refused — the zero-total implementation is red there.
  • coins_a_stranger_planted… requires the planted and empty addresses to produce the SAME refusal. "Is not TooManyInputs" alone is satisfied by an implementation that refuses everything.
  • The bound test is asserted at two sizes ( then the budget) with the counts required to be equal. One large fixture is green against any limit at or above it.
  • The three-pass runner test keeps a truthful control: the third pass genuinely funds the create and must announce a recovery. Without it the test is equally green against an implementation that never recovers.

The old a_create_needing_more_inputs_than_the_bound_is_refused_before_any_lineage_read was deleted: it asserted the defect — that the bound fires before authentication over a wallet of unauthenticatable dust — and would have kept this PR's behaviour pinned to the finding.

Blast radius checked

.gitnexus was not used: the registered index for this repo is ~301 commits behind and returns the documented false-safe impactedCount: 0, which is worse than no answer on a money seam. Done by grep with match counts, plus direct read.

  • select_operator_dig_cats_detailed — one production caller (select_operator_dig_cats), which has one production caller (lifecycle.rs:454); rest are tests. Signature unchanged.
  • FundingError — new variant; from_error's match is exhaustive, so every consumer was forced to decide. Display arm added; two existing arms corrected, both of which had gone stale (Unauthenticated still said "the whole selection is refused" after One dust coin at the public CAT address permanently blocks every mirror create #461 made it a skip; TooManyInputs still said "no coin was authenticated").
  • PassDecision — one production consumer (runner.rs:380). Field added, destructured there.
  • MAX_SELECTED_FUNDING_COINS — no consumer outside this module.
  • Verified with --all-targets, not --lib: the signature changes reach the integration tests.

Evidence

  • cargo test -p dig-node-service --all-targets726 passed; 0 failed (lib) plus every integration suite green.
  • cargo test -p dig-node-service --test mirror_operator_funding13 passed; 0 failed.
  • cargo clippy --workspace --all-targets -- -D warnings0 errors.
  • cargo fmt --all clean; no CRLF; no backslash-continued string literals (concat! throughout).

SPEC.md gains §25.11 (authentication precedes every figure the operator is told, and the read bound) and §25.12 (the shortfall-reporting contract, including that a pass which could afford nothing is short rather than healthy). Both were previously unwritten, so the ordering had no normative home.

Still DRAFT — the re-gate has not returned.

…l contract (§25.11, §25.12)

SPEC was silent on both, so neither the authenticate-before-you-report ordering
nor the empty-wallet-is-short classification had a normative home. Refs
dig-node#469
…or's real $DIG

The unit fixtures can only build candidates that FAIL authentication -- a Cat
exists only once a real creating spend has been executed -- so they prove what a
stranger's coins are worth and cannot prove that the operator's own are still
counted. These two put genuine CAT lineage beside the planted coins and vary
only the stranger, reproducing the audit's probe B and probe C.

Refs dig-node#469
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate — IN PROGRESS, not the verdict

Head being audited: c33826bb29716734271d5f7b0a5fba7f2a00ee0d (resolved from gh pr view 469 --json headRefOid). Confirmed byte-identical to 8ead74e9: git rev-parse 8ead74e9^{tree} and c33826bb^{tree} both give e4de94ca3020bf69be5b8265739830a1e4c65edd, so the content audited is the fix lane's tree and the head move was a commit-message rewrite only.

Scope: the delta fde43d3d..c33826bb (three fix commits, one spec commit, one test commit, plus the merge of a5507db from main) plus the four finding sites. Own detached worktree C:/tmp/lsec469b, own CARGO_TARGET_DIR. No shared checkout and no dn-* worktree touched.

Structural reads done so far — probes still to run

  • Finding 1 (attacker chooses the remedy) — structurally fixed. The input bound no longer runs over the raw scan. funding.rs:439-470 walks and authenticates first; funding.rs:485-491 then calls select_within_input_bound over authenticated only, and funding.rs:526-532 is the sole producer of TooManyInputs. A row a stranger paid to dig_cat_puzzle_hash(owner) never reaches a total, a selection or the bound, so it cannot select FundingRemedy::Consolidate.
  • Finding 4 (address total reported as the operator's total) — structurally fixed. Insufficient.have_dig_base_units now comes from select_largest_first over the authenticated vector (funding.rs:519-524), and TooManyInputs.have_dig_base_units from the fold at funding.rs:512-514. Both are sums over authenticated coins.
  • Finding 3 (no limiter before the expensive step) — a bound exists. MAX_AUTHENTICATION_ATTEMPTS = 128 at funding.rs:251, enforced at funding.rs:445-449. Per call the chain reads are at most 1 scan + 128 coin_spend, independent of pool size.
  • Finding 2 (ordinary shortfall classified Healthy) — structurally fixed. PassDecision.funding_shortfall is computed off the funds split at pass.rs:253-265, and runner.rs:455-462 classifies it Short/TopUp ahead of the Healthy arm. Arm order verified: the PassError::Funding arm and the non-funding Some(_) => Unknown arm both precede it (runner.rs:436-473), so a create that was attempted and refused still reports from the structured error, not from the split.

One judgement call settled, so it is not re-litigated later

The bounded walk means a stranger who plants 128 coins that out-value the honest ones exhausts the budget, and CandidatesUnverifiable maps to Unknown (funding.rs:637) — silent, and the node stops bonding. The fix lane named this residual.

It is not a regression and I will not gate on it. On origin/main today the same address is a strictly cheaper denial: the selection loop propagates authenticate's Err with ? over the largest-first prefix, so one planted coin that out-values the honest ones refuses the whole selection, permanently and equally silently (Unauthenticated also maps to Unknown). This PR raises the attacker's coin count from 1 to 128 and removes the money lie. The residual is a real follow-up — a persisted negative cache of coin ids that failed authentication would remove it, since the same 128 rows are re-read every pass forever — but it is defense-in-depth against a pre-existing condition, not a defect this diff introduces.

Probes from the original audit are being re-run against this head. Verdict to follow.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate — IN PROGRESS (2/3), not the verdict

Head c33826bb29716734271d5f7b0a5fba7f2a00ee0d. The four probes from the 00:22Z audit were rewritten against this head, in my own worktree, over the real CAT fixture harness (support::ordinary_dig_coins, so a coin either genuinely resolves or genuinely does not). Count checked, not exit status: test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out.

Forward run — the three original probes now produce the opposite of what they produced at fde43d3d

probe at fde43d3d (the finding) at c33826bb
A — reads vs pool size planted=200 chain_reads=201, no bound planted=200 reads=128 and planted=400 reads=128
B — reported total Insufficient { have: 30000 } with reads == 0 Insufficient { have_dig_base_units: 20000 } with reads=3
C — the remedy Consolidate"Consolidate the wallet DIG into fewer coins - adding more will not help", reads == 0 Insufficient { have_dig_base_units: 1000 }TopUp, reads=41

Verbatim from the run:

PROBE A: planted=200 reads=128 outcome=Err(CandidatesUnverifiable { attempted: 128, skipped: 128, need_dig_base_units: 40000 })
PROBE A: planted=400 reads=128 outcome=Err(CandidatesUnverifiable { attempted: 128, skipped: 128, need_dig_base_units: 40000 })
PROBE B: reads=3 outcome=Err(Insufficient { have_dig_base_units: 20000, need_dig_base_units: 40000 })
PROBE C: reads=41 refusal=Insufficient { have_dig_base_units: 1000, need_dig_base_units: 40000 }
PROBE C ALERT BODY: Your node cannot bond content: it needs 40.000 DIG of collateral for this epoch and
the operator wallet holds 1.000 DIG that it can spend, so it is 39.000 DIG short. Add $DIG to the
operator wallet. Until then no new content is collateralised and it earns nothing.
PROBE D: reads=1 cats=1

Three things I checked that the fix lane's own tests do not, because a bound can be satisfied by refusing everything:

  • The read bound is asserted at TWO pool sizes with the counts required to be EQUAL (200 and 400 both give 128). One size is green against any implementation whose limit happens to sit at or above it.
  • Probe C keeps a truthful control. The operator holds a real, authenticatable 1.000 DIG. The assertion is not merely "not TooManyInputs" — it requires the alert body to quote that figure, so an implementation that refused everything or quoted zero is red.
  • Probe D is the no-regression half (mine, not in the diff): a funded operator with 60 planted coins beside their honest one still funds the create, at reads=1. So the ordering change did not buy honesty by making a healthy wallet pay the budget, and it did not break funding through noise.

CandidatesUnverifiable was also asserted to map to FundingObservation::Unknown in the same probe, so the truncated walk states no total and clears no live shortfall.

Revert-proof is running now: the input bound re-applied to the raw pool, the attempt budget removed, and the Healthy arm restored — each expected to red a different probe. Verdict after that.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Two of the lane's realizations checked — one refuted, one confirmed and worth acting on

Refuted: the CI gap does not exist. The lane flagged that cargo test -p dig-node-service --all-targets does not run the mirror_* integration suites unless named with --test, and worried a whole family was unrun on PRs.

That is true of cargo test, but not of CI. .github/workflows/ci.yml:138 runs cargo llvm-cov nextest --workspace --locked, and nextest's model is one process per test binary — integration suites included. Verified on the live main run 33455376121: 2,814 tests, spanning dig-node-core and dig-node-service unit and integration binaries alike.

Worth recording why the concern was reasonable: --all-targets builds the integration binaries without running them, so a local --all-targets pass looks like coverage it is not. That is exactly how a broken caller in tests/mirror_bond_verify.rs hid behind --lib runs on a sibling PR for hours. The local trap is real; the CI one is not.

Confirmed and it matters: SPEC.md had nothing on funding selection at all. The #427 input bound shipped with no normative clause, which is why nothing flagged that a bound was being applied to attacker-supplied rows before authentication. This PR adds §25.11 and §25.12; both contracts were previously unwritten.

That is the more useful finding of the two. A guard with no normative statement behind it cannot be checked against anything — a reviewer sees code that looks defensive and has no contract to test it against. It is the same shape as the old bound test encoding the defect and passing the triple gate that shipped #427: with nothing written down, "what this must do" and "what this happens to do" are indistinguishable.

Residual, named rather than hidden

A stranger who buries the honest coins under 128 larger unauthenticatable ones stops this node bonding until the wallet is consolidated. Any constant bound has this shape. What is gone is the money lie — the operator is no longer told a falsehood about their own balance, and the remedy shown is no longer attacker-chosen.

A provider cache of previously-authenticated coin ids across passes would remove the residual. Worth its own ticket; deliberately not this PR.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate (resumed) — IN PROGRESS, not the verdict

Resuming the re-gate that stopped after the 02:37Z probe run. Head audited: c33826bb29716734271d5f7b0a5fba7f2a00ee0d, re-resolved from gh pr view 469 --json headRefOid at 05:29Z — unchanged. Tree e4de94ca3020bf69be5b8265739830a1e4c65edd.

Disclosure: I reused the dead gate's worktree rather than cutting a new one

C:/tmp/lsec469b is the previous gate session's own detached worktree at this exact SHA, with a 13 GB warm CARGO_TARGET_DIR at C:/tmp/lsec469b-target. I measured it dead before touching it: last write to the target dir was 02:37:47Z (the moment its probe comment posted), i.e. ~3 hours idle, and the session is confirmed terminated.

I reused it because C: has 4.5 GB free with sibling lanes actively compiling (live cargo.exe + 9 rustc.exe). A second cold target dir here would very likely ENOSPC, which is a hard STOP and would take live lanes down with it. This is a dead peer's private scratch, not a shared checkout. No shared checkout and no dn-* worktree was touched.

What I found in it, which matters for the record: the previous gate died mid-revert-proof. Its worktree carried three reverts applied simultaneously:

  • the input bound re-applied to the raw pool, pre-authentication (finding 1/4 fix reverted);
  • MAX_AUTHENTICATION_ATTEMPTS removed (finding 3 fix reverted);
  • None if decision_shortfall.is_some() && false in runner.rs:455 (finding 2 fix reverted).

I preserved the patch and its probe file, then restored the two source files to HEAD. The tree now hashes to e4de94ca… again, so every read below is of the audited content. Its uncommitted state means no revert-proof was ever executed — the 02:37Z comment's closing line ("revert-proof is running now") describes work that did not complete.

What is already discharged, and what I am finishing

Discharged by the 02:37Z forward run (4 passed, count-checked, over the real CAT fixture harness):

  • finding 1 — a stranger's rows no longer reach the bound; probe C yields TopUp quoting the operator's own 1.000 DIG;
  • finding 3 — reads bounded at 128 for both a 200-coin and a 400-coin pool, counts required equal;
  • finding 4 — Insufficient { have_dig_base_units: 20000 } after real lineage reads, not the 30000 address total;
  • no-regression — a funded operator still funds at reads=1.

Not discharged, and what I am doing now:

  1. Finding 2 has no executable evidence at this head — only a structural read. It is the one finding whose fix nobody has run a probe against.
  2. No revert-proof was executed, so the probes' non-vacuity rests on the fde43d3d-vs-c33826bb comparison of rewritten probes rather than one probe over two trees.
  3. The fix's own new surface has not been audited adversariallyMAX_AUTHENTICATION_ATTEMPTS, CandidatesUnverifiable, select_within_input_bound, and PassDecision.funding_shortfall are all new code introduced by the remediation, and a scoped re-gate owes them a fresh threat model rather than a diff-read.

Starting with (3), which costs no build. Verdict to follow.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate (resumed) — IN PROGRESS (2), not the verdict

Head c33826bb29716734271d5f7b0a5fba7f2a00ee0d. Finding 2 now has the executable evidence it was missing, forward and reverted. This is the gap the interrupted session left.

Finding 2 — forward run, counts checked

test mirror::runner::tests::a_wallet_that_can_afford_nothing_alerts_short_and_never_announces_a_false_recovery ... ok
test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 706 filtered out

Finding 2 — revert-proof, executed (the prior session applied this revert but never ran it)

Revert applied alone, in the restored tree: runner.rs:455 changed to
None if decision_shortfall.is_some() && false =>, which routes the ordinary shortfall back to the
Healthy arm — the exact 00:22Z finding.

thread '...a_wallet_that_can_afford_nothing_alerts_short_and_never_announces_a_false_recovery'
panicked at crates\dig-node-service\src\mirror\runner.rs:895:41:
a wallet that cannot afford a single create raised no alert; this is the commonest real
shortfall there is, and it was being reported as a healthy node

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 725 filtered out

Tree restored to e4de94ca… afterwards.

Why I believe this test is not vacuous, beyond the revert

It carries a truthful control: pass 3 funds the create for real (third.created == vec![capsule]
is asserted) and must announce a recovery with remedy == None. Without that, the test is equally
green against an implementation that never recovers — which would leave an operator who fixed their
wallet permanently told it is broken. Only the balance varies across the three passes, so the
distinction the test draws is the wallet's, not the gate's. Pass 2 asserts silence, which is what
catches the clearing half rather than only the missing producer half.

Arm order verified independently

runner.rs:436-473, in order: PassError::Funding(cause)from_error; any other Some(_)
Unknown; None if decision_shortfall.is_some()Short { TopUp }; None if per_coin.is_some()
Healthy; NoneUnknown. A create that was attempted and refused therefore still reports
from the structured error and never from the split, and an unrelated failure still cannot clear a
shortfall.

FundingObservation::from_error is an exhaustive match with no wildcard — I checked, because a
wildcard is how a future variant would silently acquire a classification. CandidatesUnverifiable
Unknown, which raises nothing and clears nothing.

Independently confirmed: the 128-budget residual is NOT a regression

The fix lane and the interrupted gate both assert this; I verified it against the tree that would
actually be merged into rather than against the PR's own earlier state. On the merge-base
a5507db (current main), funding.rs selects largest-first over the raw candidates and then
does cats.push(authenticate(source, record, owner_puzzle_hash)?) — the ? propagates, so one
unauthenticatable coin in the selected prefix refuses the entire selection, permanently and
silently (Unauthenticated also maps to Unknown). Main's denial threshold is 1 planted coin;
this PR's is 128. Strictly better, and the money lie is gone.

Main also confirms both fixed defects are live there today: TooManyInputs has no
have_dig_base_units field at all and fires over raw candidates (finding 1), and
Insufficient { have_dig_base_units: available } is the address total (finding 4).

Probes A–D re-running at this head under my own hand. Verdict after that.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate — VERDICT: PASS

Head audited: c33826bb29716734271d5f7b0a5fba7f2a00ee0d, resolved from gh pr view 469 --repo DIG-Network/dig-node --json headRefOid at the start (05:29Z) and re-resolved unchanged at 05:56Z. Tree e4de94ca3020bf69be5b8265739830a1e4c65edd. Merge-base with main: a5507dbf62a4f3dd53522865cfc4c9fc569cb6d1. Scope: the lane's own delta a5507db..c33826bb plus the four 00:22Z finding sites — a scoped re-gate of the fixes, not a fresh full-crate audit.

This completes the re-gate that stopped after 02:37Z. All four findings are fixed, and every one now has both a forward run and a revert-proof that I executed myself at this head. The prior session had applied its reverts but died before running any of them.


Per-finding status

# 00:22Z finding status forward evidence revert-proof (executed here)
1 attacker chooses the remedy; an operator holding nothing is told they hold enough and that adding more will not help FIXED probe C gives Insufficient { have: 1000 } then TopUp, body quotes the operator's real 1.000 DIG R1 gives 2 passed; 2 failed, probe C reproduces the Consolidate lie verbatim
2 ordinary shortfall classified Healthy; #463 vacuous for its main case, plus a false recovery FIXED a_wallet_that_can_afford_nothing_alerts_short_and_never_announces_a_false_recovery in a 20 passed; 0 failed run R2 gives 0 passed; 1 failed
3 no limiter before the expensive step; one chain round trip per planted coin, every pass FIXED probe A gives planted=200 reads=128 and planted=400 reads=128 R3 gives 3 passed; 1 failed, probe A red at left: 201, right: 128
4 Insufficient { have } reports the address total; deficit understated then suppressed FIXED probe B gives Insufficient { have_dig_base_units: 20000 } after reads=3 R1 reds probe B at left: 30000, right: 20000

Finding 1 — fixed, and the reason it is fixed is structural

funding.rs:437-470 authenticates first; funding.rs:485-491 calls select_within_input_bound over authenticated only; funding.rs:526-532 is the sole producer of TooManyInputs. A row a stranger paid to dig_cat_puzzle_hash(owner) never reaches a total, a selection, or the bound.

The remaining question a re-gate owes is whether Consolidate can still be attacker-selected by other means. It cannot, and the reason is economic rather than syntactic: to put more than 32 coins into authenticated, an attacker must supply coins that pass authenticate — real $DIG CAT lineage resolving to this operator's p2_puzzle_hash (funding.rs:786-822). That is a gift of at least need in real $DIG to the operator, after which "you hold enough, in too many pieces" is true and consolidating is the correct remedy. The message now tracks reality in both branches.

Finding 2 — fixed, arm order verified independently

runner.rs:436-473, in order: PassError::Funding(cause) to from_error; any other Some(_) to Unknown; None if decision_shortfall.is_some() to Short { TopUp }; None if per_coin.is_some() to Healthy; None to Unknown. A create that was attempted and refused still reports from the structured error and never from the split; an unrelated failure still cannot clear a shortfall.

The test is non-vacuous beyond its revert: it carries a truthful control — pass 3 genuinely funds the create and must announce a recovery with remedy == None — so it is not green against an implementation that simply never recovers. Only the balance varies across the three passes.

Findings 3 and 4 — fixed, and the bound is a constant in the property that matters

MAX_AUTHENTICATION_ATTEMPTS = 128 (funding.rs:251) enforced at funding.rs:445-449. Per call: at most 1 scan plus 128 coin_spend, independent of pool size. Probe A asserts this at two pool sizes with the counts required to be equal, which one size cannot do. Probe D is the no-regression half: a funded operator with 60 planted coins beside their honest one still funds the create at reads=1, so the ordering change did not buy honesty by making a healthy wallet pay the budget.

FundingObservation::from_error is an exhaustive match with no wildcard — checked, because a wildcard is how a future variant would silently acquire a classification. CandidatesUnverifiable maps to Unknown, which raises nothing and clears nothing.


The 128-budget residual is not a regression — verified against the tree that would be merged into

Both the fix lane and the interrupted gate assert this; I verified it against main, not against the PR's own earlier state. On the merge-base a5507db, funding.rs selects largest-first over the raw candidates and then calls authenticate with the ? operator inside the loop, so one unauthenticatable coin in the selected prefix refuses the entire selection, permanently and just as silently (Unauthenticated also maps to Unknown).

Main's denial threshold is 1 planted coin; this PR's is 128. Main also carries both fixed defects live today: TooManyInputs there has no have_dig_base_units field at all and fires over raw candidates, and Insufficient { have_dig_base_units: available } is the address total. This diff strictly improves both the DoS threshold and the honesty of every operator-facing figure.


What I checked at this head that is CLEAN

  • No new operator-facing money figure is derived from an unvalidated address balance. This was the live risk in the finding-2 fix, since FundingShortfall (pass.rs:253-265) computes have as balance % per_coin from the balance oracle rather than from authenticated coins. Traced it: dig_balance_base_units (dig-wallet/src/sage/rpc.rs:1271) calls balance_for_address(address, BalanceAsset::DIG)asset-scoped, and keyed on the bare owner puzzle hash rather than the CAT-wrapped scan address. Dust or a foreign asset paid to dig_cat_puzzle_hash(owner) therefore cannot inflate it. A stranger who can move that number has paid the operator real $DIG.
  • per_coin > 0 is guarded before the modulo (pass.rs:258), so the new arithmetic has no division-by-zero path.
  • Log amplification is now bounded. The per-skipped-candidate WARN (funding.rs:471-480) sits inside the budgeted loop, so it is capped at 128 lines per pass rather than the previous unbounded N. skipped is likewise bounded by attempts.
  • Identifier hygiene holds for the new refusal. CandidatesUnverifiable Display carries only two counts and an amount — no coin id, no address. Both alert producers still interpolate exclusively through whole_dig, which always emits a dot, so no token can be a run of 16 or more hex digits.
  • No new panic surface on the production path. Exactly one added non-test expect in the delta — decision_shortfall.expect("matched Some directly above") at runner.rs:456 — and its match guard None if decision_shortfall.is_some() proves it. Every other added expect/unwrap is in tests. No unsafe. Arithmetic is saturating, checked, or guarded.
  • Dependencies: none added or loosened. The only Cargo.lock change against the merge-base is the dig-node-service version line, 0.206.0 to 0.207.0. Cargo.toml:35 agrees.
  • Secrets: none. No key, token, credential or projectId introduced, logged, or committed.
  • SPEC 25.11 and 25.12 match the code they describe. I read them against the implementation clause by clause rather than accepting them as documentation. They are normative, testable, and they close the gap the 03:50Z note identified — the mirror funding: unbounded selected-input count on a publicly-derivable address, one chain read per input #427 bound had shipped with no normative clause at all, which is why nothing flagged a bound being applied to attacker-supplied rows.
  • Suites at this head, counts checked, not exit status: --lib mirror:: gives 139 passed; 0 failed (587 filtered); --test mirror_operator_funding gives 13 passed; 0 failed; 0 filtered; --lib mirror::runner gives 20 passed; 0 failed; my own probe binary gives 4 passed; 0 failed; 0 filtered.

Defense-in-depth — named, NOT gated

  1. The 128-budget residual DoS. A stranger who buries the honest coins under 128 larger unauthenticatable ones exhausts the walk, giving CandidatesUnverifiable then Unknown, so the node stops bonding and says nothing, every pass, until the planted coins are spent. Planting is cheap: the coins need only out-declare the operator's honest amounts, and the puzzle hash is publicly derivable. Strictly better than main (1 coin), so not gating — but the follow-up both the lane and the interrupted gate named is the right one: a persisted negative cache of coin ids that failed authentication, since the same 128 rows are re-read every pass forever. Worth its own ticket.
  2. Healthy is inferred, not measured, when no create was planned. A broke operator who has been alerted Short and then has nothing left to bond gets "The operator wallet can fund mirror collateral again. Your content is being bonded on the next pass." Both clauses are unverified in that branch. SPEC 25.12 explicitly blesses "nothing to bond" as healthy, so this is a stated design decision rather than a drift — but the recovery message asserts more than the observation establishes. Narrow (it needs the shortfall's cause to disappear), so not gating.
  3. The balance-oracle versus scan disagreement is now operator-visible. Coins committed to an in-flight bundle are excluded from selection (funding.rs:419) but counted by the balance oracle, so a pass can attempt a create the scan then refuses, producing a TopUp alert telling an operator to buy $DIG while their money is merely in flight. Pre-existing, and named by the fix's own test doc; what is new is that it now renders to a person. Bounded by confirmation time and deduped by grew_materially.
  4. PassReport::funding_alert still has no production consumer beyond the tracing::warn! at runner.rs:461. Unchanged from the 00:22Z note; recorded so the identifier-hygiene rule is not later relaxed on the belief that nothing renders it.

Disclosure and limits

I reused the previous gate session's own detached worktree C:/tmp/lsec469b (at this exact SHA) and its 13 GB warm CARGO_TARGET_DIR, rather than cutting a new one. Measured dead first: last write 02:37:47Z, roughly 3 hours idle, session confirmed terminated. The reason was that C: had 4.5 GB free with sibling lanes actively compiling, where a second cold target dir would very likely ENOSPC and take live lanes down with it. No shared checkout and no dn-* worktree was touched.

I found three reverts still applied in that worktree — the pre-authentication bound, the removed attempt budget, and && false on the shortfall arm — all uncommitted and unexecuted. I preserved them, restored the two source files, and confirmed the tree hashes to e4de94ca… before every read and every run above. Each revert-proof was then applied alone and restored, so the attribution is clean: R3 reds only probe A, R1 reds only probes B and C, R2 reds only the runner test.

Not covered: no live-node or on-chain reproduction — everything here is the unit and integration seam plus static reading. I did not audit the spend_audit.rs and mirror_funding_reservation_expiry.rs changes, which arrived from main via a5507db (#475) and are outside this re-gate's scope. gitnexus was not used; it returns the documented false-safe impactedCount: 0 on this repo, so blast radius was grep plus direct read with match counts checked.

Verdict

PASS. All four findings from the 00:22Z audit are fixed at c33826bb, each proven both forward and by an executed revert. The fix introduces no new money lie, no new panic or overflow surface, no new dependency, and no secret. The one residual it does introduce — a 128-coin denial — is strictly weaker than the 1-coin denial live on main today, and is recorded above as a follow-up rather than a gate.

My probe file and scratch are removed; the worktree is left clean at HEAD, tree e4de94ca….

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Lane update — conflicts resolved, and the gate finding is genuinely fixed

Outcome 1: still needed and distinct. §2.0 check against origin/main (12a3f49). Main's mirror/funding.rs does already carry FundingError::Unauthenticated, MAX_SELECTED_FUNDING_COINS = 32 and tests/mirror_operator_funding.rs, so this is not a from-scratch delta — but main's ordering is the pre-fix one, and its own doc says so:

funding.rs on origin/main: "The refusal happens AFTER selection and BEFORE authentication ... an attacker cannot cheaply force it (see dig-node#461 for the cheap attack that DOES exist on this path)"

This branch is what closes #461. The branch delta over main is +1829/-355 across funding.rs, pass.rs, runner.rs, lifecycle.rs, SPEC §25.11/§25.12 and seven test files.

The gate finding — authenticate-before-bound — is fixed

The bound no longer sees a stranger's coins. select_within_input_bound (funding.rs:508) is documented as taking coins "already proven spendable by the caller", and the caller (:485) hands it only the authenticated vector built by the loop at :437-470. An unauthenticatable candidate is passed over into skipped, never counted toward the 32.

The DoS the bound existed to stop is now bounded separately and correctly by MAX_AUTHENTICATION_ATTEMPTS = 128 (:251), which caps chain reads without letting dust choose the operator's remedy.

Revert-proof — the tests are load-bearing

Committed first, then mutated a copy: re-inserted the pre-fix ordering (bound the RAW candidate pool before authentication) at the top of the selection walk.

run result
as committed ok. 18 passed; 0 failed; 712 filtered out
pre-fix ordering re-inserted FAILED. 16 passed; 2 failed; 712 filtered out

The two that go red:

  • coins_a_stranger_planted_never_become_a_statement_about_the_operators_money
  • authentication_is_bounded_by_a_constant_however_many_coins_a_stranger_sends

Test count checked, not just exit status — a filtered-to-nothing run also exits 0. Mutation reverted; tree clean.

State

  • branch: loop/mc-fund
  • head: 08befdf
  • version: 0.222.0 (read from Cargo.toml on disk after merging, not from the log)
  • git merge origin/main, never rebase; conflicts were Cargo.toml + Cargo.lock only, resolved by taking main's lock then cargo update -w --offline in the same commit

Next action: wait for CI on 08befdf, then gate. Not undrafted, not merged.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Head moved — origin/main advanced to a375f46 (#477) mid-pass, which put this back to DIRTY. Re-merged (git merge, never rebase); this time zero conflicts, and the version was re-read from Cargo.toml on disk afterwards rather than taken from the commit log.

  • head: b88e522
  • version: 0.222.0 (still above main's 0.217.0)
  • mergeStateStatus: BLOCKED (draft + checks pending), no longer DIRTY

Next action: watch CI on b88e522, then gate. Still draft, not merged.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

The stale-comment concern: measured, and it was already fixed on this branch

Flagged as a required fix. Measured on the merged head b88e522the branch does not carry main's pre-fix doc block. It was replaced by this PR's own commits (424206f, f7964fb) and survived the merge intact.

funding.rs:200-205 on this branch now reads:

The refusal happens over the AUTHENTICATED candidates only, and never over the raw scan. The scan address is public, so the count of rows at it is chosen by whoever last paid a coin to it; a bound applied to that count is a bound an attacker sets, and the refusal it produces is an operator-facing money statement (FundingRemedy::Consolidate) that the same attacker therefore chooses. Authentication is what turns a row into one of this operator's coins, so it runs first and the bound is applied to what survives it (dig-node#469).

The paragraph on origin/main that argued the opposite — "The refusal happens AFTER selection and BEFORE authentication, which is the only placement that achieves the point", plus its "see dig-node#461 for the cheap attack that DOES exist on this path, which is ... not this bound" — is gone from this branch entirely, and merging replaces main's copy with this one.

Swept, not assumed

grep -rn "AFTER selection and BEFORE authentication|Bounding the CANDIDATE set" --include=*.rs --include=*.md

One hit, and it is correct: funding.rs:1240, in the test's doc, naming the defect the test catches — "Catches: the dig-node#469 finding 1 — the input bound returning BEFORE authentication began". A test that names the behaviour it guards against is the opposite of a stale claim.

The two surviving #461 references are also correct rather than residual: funding.rs:350 ("An unauthenticatable candidate is SKIPPED, not fatal") and tests/mirror_operator_funding.rs:410. #461 is the skip-not-fatal fix, which this PR implements — so the citation now points at work this PR does, not at an outstanding attack.

SPEC.md agrees: §25.11 is titled "Funding a create — authentication precedes every figure the operator is told."

Did the comment fix need a test?

No, and stating why rather than leaving it implicit. The doc change carries no behavioural delta — it describes an ordering that this PR already changed in code and that is already pinned from both sides by the_input_bound_is_decided_over_authenticated_coins_and_holds_from_both_sides (at-bound passes, one over fails). A doc-only edit with no behaviour change cannot have a test that fails without it; inventing one would assert a property the code has on a fixture that cannot exhibit the edit.

The behaviour the comment now describes is proven load-bearing by the revert-proof already posted above: re-inserting the pre-fix ordering takes the suite from 18 passed; 0 failed to 16 passed; 2 failed.

Head after re-merging main (#477)

  • head: b88e522
  • workspace version: 0.222.0, read from Cargo.toml on disk after the merge — clear of main's 0.217.0
  • dig-node-core: left at 0.65.0, matching main deliberately — this PR touches only dig-node-service, so there is nothing in that crate to increment
  • zero conflicts on the re-merge; mergeStateStatus BLOCKED (draft + checks), not DIRTY

Still draft, not merged.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Auditing at head b88e5222b1630d5043dfee2ab50cf228b82a8244 (resolved from gh pr view 469 --json headRefOid), merge-base a375f462318c2f55a616ba595de2a539aadb6b97. Read-only: PR content read via git show <ref>:<path>; the primary dig-node checkout is parked on pr409 and has not been touched.

Confirmed so far

The ordering fix is real. select_operator_dig_cats_detailed (crates/dig-node-service/src/mirror/funding.rs:439-470) authenticates each candidate inside the walk loop and only pushes to authenticated on Ok; the input bound is then applied by select_within_input_bound (:485, :526) over authenticated alone. An unauthenticatable candidate goes to skipped (:465) and never reaches a slot, a total, or the bound. The pre-fix shape (bound over the raw scan) is gone.

Both operator-facing money figures now derive from authenticated coins only. have_dig_base_units in TooManyInputs is authenticated_total (:530, folded at :512-514), and in Insufficient it is shortfall.have from select_largest_first over the authenticated vector (:519-523). Neither is computed over the raw scan.

Under active examination (no verdict yet)

  1. MAX_AUTHENTICATION_ATTEMPTS = 128 (funding.rs:251) — cost to an attacker of planting 128 coins that sort ahead of every honest coin, and whether the resulting refusal is silent.
  2. Whether select_within_input_bound's ordering guarantee is structural or conventional — it is generic over T, so the type does not carry the authentication.
  3. What the operator is actually shown when the cap is hit (CandidatesUnverifiable -> FundingObservation::Unknown, which by its own doc "never alerts and never clears").

Next: the alert gate, pass.rs/runner.rs surfacing, then the revert-proof re-run in a private worktree.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (findings before the revert-proof)

Head audited: b88e5222b1630d5043dfee2ab50cf228b82a8244. Established by direct read of the pushed tree.

CONFIRMED CLEAN

Brief item 1 — the ordering. Structural in practice, conventional in the type. select_within_input_bound is module-private (funding.rs:508), has exactly one non-test caller (funding.rs:485), and that caller passes authenticated only, built at :453 on the Ok arm of authenticate. The bound at :526 cannot see an unauthenticatable coin. But the function is generic over T and re-checks nothing (:505-507 says so), so the guarantee lives in the call site, not the signature. See D1.

Brief item 4 — largest-first steering. No cheap steer. To influence post-auth ordering an attacker must send real $DIG that passes authenticate (funding.rs:814-821 checks asset id and p2 puzzle hash), i.e. gift the operator money. Tuning amounts to force 33 inputs yields TooManyInputs/Consolidate, but that message is then TRUE. Economically self-defeating; not gating.

Stale comment. Genuinely gone from the pushed branch. git grep "AFTER selection and BEFORE authentication" b88e522 returns no hits; the same grep on a375f46 hits funding.rs:162. The #461-as-live-attack claim at main :170-171 is gone too.


G1 — GATING, HIGH. A second operator-facing money figure is computed from the UNAUTHENTICATED address total — which SPEC §25.11, added by this same PR, says it MUST NOT be.

The PR fixes the figure inside funding.rs, then introduces a new one in pass.rs/runner.rs that is not authenticated at all. The alert channel is itself new here: FundingAlertGate does not exist on a375f46.

The chain:

  • pass.rs:257-262FundingShortfall { have_dig_base_units: balance % per_coin, .. }, where balance is inputs.dig_balance_base_units.
  • runner.rs, the None if decision_shortfall.is_some() arm — maps it to FundingObservation::Short { have_dig_base_units, need, remedy: TopUp }.
  • funding.rs:707 to shortfall_alert, rendered at funding.rs:742-752: "the operator wallet holds {have} DIG that it can spend, so it is {short} DIG short."

That have traces to lifecycle.rs:806 to WalletBackend::dig_balance_base_units to balance_for_address(addr, BalanceAsset::DIG), and neither tier authenticates lineage:

  • fallback tier — crates/dig-wallet/src/sage/rpc.rs:1367-1375 builds asset_hashes from asset.cat_coin_puzzle_hash(ph) and keeps every coin whose puzzle hash matches. A pure puzzle-hash filter.
  • replica tier — crates/dig-wallet/src/sage/db.rs:3082-3092 calls unspent_coins_scoped, whose SQL (:3066-3068) is SELECT ... WHERE spent_height IS NULL AND created_height IS NOT NULL AND hint IN (...) AND asset_id = ?, then a plain .sum(). No lineage anywhere.

SPEC §25.11 as added by this PR: "The node MUST authenticate before it computes any figure it reports... The spendable total in a shortfall MUST be the total of AUTHENTICATED candidates. It MUST NOT be the address total."

The §25.12 path is the address total. §25.11 and §25.12 contradict each other as written, and the implementation satisfies §25.12 while violating §25.11 — on the path §25.12 itself calls "the commonest real case". Merging ships a normative claim the same commit falsifies.

Exploit. Operator holds 500 base units; epoch per_coin is 1,000; one capsule to bond. A stranger derives dig_cat_puzzle_hash(owner) — publicly derivable, as the module doc states at funding.rs:179-181 — and plants one unauthenticatable coin of 499 base units. Reported balance becomes 999, still under per_coin, so affordable is empty, no create is attempted, stopped_at is None, and the decision_shortfall arm fires. The operator is told "holds 0.999 DIG that it can spend, so it is 0.001 DIG short" when they are 0.500 short. Attacker cost: 499 mojos.

Taken alone this self-corrects on the pass after the operator tops up, because the create is then attempted and the authenticated Insufficient figure wins. G2 is what makes it stick.


G2 — GATING, HIGH. MAX_AUTHENTICATION_ATTEMPTS = 128 is a cheap, permanent and SILENT denial of bonding, and it composes with G1 into a durable false money statement.

The constant's own doc concedes the denial (funding.rs:247-250): "An attacker who buries the honest coins under 128 larger unauthenticatable ones can stop this node bonding." My finding is the cost and the silence, not the concession.

Cost. The walk is largest-first on record.coin.amount (funding.rs:426-431), and a coin amount at the CAT-wrapped hash is denominated in mojos. Out-ranking an operator holding 100,000 DIG needs 128 coins of about 1e8 mojos each — roughly 0.0128 XCH, one time. Those coins have no valid CAT lineage, so nobody can ever spend them; they sit at the address and are re-walked on every pass, forever. The budget is consumed by attacker coins before any honest coin is reached, so authenticated_total stays 0, walked_whole_pool is false, and funding.rs:475-481 returns CandidatesUnverifiable on every pass indefinitely.

Silence. CandidatesUnverifiable maps to FundingObservation::Unknown (funding.rs:637); FundingAlertGate::observe returns None on Unknown (funding.rs:681); so funding_alert is None and the tracing::warn! in runner.rs::execute — gated on funding_alert.is_some() — does not fire either. The alert channel says nothing, ever. The only records are stopped_at on the §25.8 surface and up to 128 tracing::warn! lines per selection at funding.rs:456.

The design intent is right and I agree with it: a truncated walk must not quote a total. But "say nothing about the amount" and "say nothing at all" are different, and the code does the second. An operator whose node has silently stopped bonding is never notified.

The composition, which is the real finding. For well under a cent a stranger can:

  1. Plant a moderate coin so the reported balance sits just under per_coin — operator is alerted "you are 0.001 DIG short" (G1).
  2. Plant 128 large unauthenticatable coins.
  3. The operator tops up the 0.001 they were told about. The balance crosses per_coin, a create is attempted, selection burns all 128 attempts on the planted coins, and the result is CandidatesUnverifiable to Unknown — no alert, and Unknown explicitly does not clear the gate state (funding.rs:657-659).

The gate alerted field stays latched on the understated deficit. The correction never arrives, the node never bonds, and the last thing the operator was told is that they were 0.001 DIG short and have since fixed it. That is a surface lying about money and about whether a privileged action took effect.

On the constant itself: 128 is not unsound as a cost bound and I am not asking for a different number. The defect is that exhausting it is unannounced. Separately, the bound is per selection, not per passcreate is called once per bond (lifecycle.rs:426-460), so a pass planning K creates costs up to K x 128 reads under block_in_place. The doc claim at funding.rs:239 that "one pass costs at most this many reads" is false as written unless the create loop breaks on first failure.


D1 — defense-in-depth, LOW (do NOT gate). skipped is discarded at the only production call site.

funding.rs:364-366 claims "A skip is counted and reported, never swallowed." In production it is swallowed: lifecycle.rs:454 calls select_operator_dig_cats, whose body is .map(|selection| selection.cats) (funding.rs:335-336). The skipped vector reaches tests only. The production record is the per-candidate tracing::warn! at :456 — up to 128 warn lines per selection per create per 10-minute pass, an attacker-driven log-volume lever of roughly 18k lines/day/create. Bounded; follow-up ticket, not a gate.

D2 — defense-in-depth, LOW. Two different FundingObservation types now coexist.

crate::wallet_funded::FundingObservation (pre-existing, server.rs:2796 on main) and the new crate::mirror::funding::FundingObservation. Same name, same problem domain, different semantics. §2.0 centralize-rivals; follow-up, not a gate.


Next: the revert-proof re-run in a private worktree, then the verdict.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security: CHANGES-REQUIRED

Head audited: b88e5222b1630d5043dfee2ab50cf228b82a8244 — re-resolved from gh pr view 469 --json headRefOid after the audit, unchanged throughout. Merge-base a375f462318c2f55a616ba595de2a539aadb6b97.

The fix this PR was asked to make is CORRECT, and I verified it by mutation. The two GATING findings are money-honesty defects of the same class, reachable through paths the fix does not cover.


Revert-proof — reproduced independently

Run in my own detached worktree (C:\tmp\wt-sec469\tree), never in a shared checkout.

run result
baseline at b88e522 18 passed; 0 failed; 712 filtered out
pre-fix ordering re-inserted 16 passed; 2 failed; 712 filtered out

Both failures are the named tests, and the failure text is the defect itself:

coins_a_stranger_planted_never_become_a_statement_about_the_operators_money
  left:  TooManyInputs { needed: 33, limit: 32, have_dig_base_units: 33, need_dig_base_units: 33 }
  right: Insufficient  { have_dig_base_units: 0, need_dig_base_units: 33 }

authentication_is_bounded_by_a_constant_however_many_coins_a_stranger_sends
  "one pass must never read more than the budget"   left: 0   right: 128

A Consolidate remedy quoted at 33 base units to an operator holding zero — exactly the #469 defect. The mutation was a 24-line insertion applied to a copy and restored by cp; git status --porcelain and git diff in the worktree are both empty and HEAD is unchanged. Test COUNTS were checked, not just exit status (the RC=0 in my log is the | tail pipeline status, not cargo).

Confirmed clean

  • Ordering is real. select_within_input_bound (funding.rs:508) is module-private with one non-test caller (:485) that passes authenticated only, built at :453 on the Ok arm of authenticate. The bound at :526 cannot see an unauthenticatable coin.
  • Largest-first steering, post-auth. No cheap steer: influencing the order requires sending real $DIG that passes funding.rs:814-821, i.e. gifting the operator money, and the resulting Consolidate message is then true.
  • Stale comment removed. git grep "AFTER selection and BEFORE authentication" b88e522 returns no hits; it hits funding.rs:162 on a375f46. The One dust coin at the public CAT address permanently blocks every mirror create #461-as-live-attack claim is gone too.
  • Create loop breaks on first failure (runner.rs:427), so a failing pass costs one selection.

G1 — GATING, HIGH: a second operator-facing money figure is the UNAUTHENTICATED address total

The PR fixes the figure in funding.rs, then adds a new one in pass.rs/runner.rs that is not authenticated at all. The alert channel is itself new here: FundingAlertGate does not exist on a375f46.

pass.rs:257-262 (have_dig_base_units: balance % per_coin) to runner.rs None if decision_shortfall.is_some() arm to FundingObservation::Short{.., TopUp} to funding.rs:707 to shortfall_alert, rendered at funding.rs:742-752 as "the operator wallet holds {have} DIG that it can spend".

balance traces to lifecycle.rs:806 to dig_balance_base_units to balance_for_address, and neither tier authenticates lineage:

  • fallback — crates/dig-wallet/src/sage/rpc.rs:1367-1375, a pure puzzle-hash filter built from cat_coin_puzzle_hash(ph);
  • replica — crates/dig-wallet/src/sage/db.rs:3082-3092 via :3066-3068, SELECT ... hint IN (...) AND asset_id = ? then a plain .sum().

SPEC §25.11, added by this PR: "The node MUST authenticate before it computes any figure it reports... The spendable total in a shortfall MUST be the total of AUTHENTICATED candidates. It MUST NOT be the address total."

The §25.12 path is the address total. §25.11 and §25.12 contradict each other, and the code satisfies §25.12 while violating §25.11 — on the path §25.12 itself calls "the commonest real case". Merging ships a normative claim the same commit falsifies.

Exploit. Operator holds 500 base units, per_coin is 1,000, one capsule to bond. A stranger derives dig_cat_puzzle_hash(owner) — publicly derivable, as funding.rs:179-181 states — and plants one unauthenticatable coin of 499. Reported balance becomes 999, still under per_coin, so affordable is empty, no create is attempted, stopped_at is None, and the decision_shortfall arm fires: "holds 0.999 DIG that it can spend, so it is 0.001 DIG short", when they are 0.500 short. Attacker cost: 499 mojos.

The author already identified this exact failure mode — funding.rs:1311-1315 describes it verbatim — and fixed one of its two producers.

G2 — GATING, HIGH: exhausting MAX_AUTHENTICATION_ATTEMPTS is SILENT, and it makes G1 durable

CandidatesUnverifiable maps to Unknown (funding.rs:637); observe returns None on Unknown (:681); so funding_alert is None and the tracing::warn! in runner.rs::execute, gated on funding_alert.is_some(), does not fire either. The alert channel says nothing, ever. Only stopped_at on the §25.8 surface and up to 128 warn lines at :456 record it.

I agree with the design intent — a truncated walk must not quote a total. But "say nothing about the amount" and "say nothing at all" are different, and the code does the second.

The composition, for well under a cent. Plant one coin so the reported balance sits just under per_coin (operator is alerted "you are 0.001 DIG short"), plus 128 large unauthenticatable coins. The operator tops up the 0.001; the balance crosses per_coin; a create is attempted; selection burns all 128 attempts on the planted coins, giving Unknown — no alert, and Unknown explicitly does not clear the gate (funding.rs:657-659). The gate stays latched on the understated deficit, the correction never arrives, the node never bonds, and the last thing the operator was told is that they were 0.001 DIG short and have since fixed it. That is a surface lying about money and about whether a privileged action took effect.

I am not asking for a different constant. 128 is sound as a cost bound; the defect is that exhausting it is unannounced.

G3 — GATING, MEDIUM: permanent chain-read amplification, and a false doc claim

funding.rs:239 claims "one pass costs at most this many reads". That is false: the bound is per selection, and create runs once per bond (lifecycle.rs:426-460). Planting MAX_AUTHENTICATION_ATTEMPTS - 1 = 127 coins ranked above the honest coins leaves every create still succeeding — so runner.rs:427 never breaks — while each pays 127 wasted chain reads. K creates x 127 reads x 144 passes/day, indefinitely, under block_in_place, from a one-time spend of roughly 0.01 XCH. The limiter runs, but it does not bound the per-pass cost its own doc says it bounds.

D1 — defense-in-depth, LOW (do NOT gate): skipped is discarded in production

funding.rs:364-366 claims a skip is "counted and reported, never swallowed". lifecycle.rs:454 calls select_operator_dig_cats, whose body is .map(|selection| selection.cats) (:335-336), so skipped reaches tests only. The production record is the per-candidate warn at :456 — an attacker-driven log-volume lever of roughly 18k lines/day/create. Follow-up ticket.

D2 — defense-in-depth, LOW (do NOT gate): rival FundingObservation types

crate::wallet_funded::FundingObservation (pre-existing, server.rs:2796) and the new crate::mirror::funding::FundingObservation — same name, same domain, different semantics (§2.0 centralize-rivals). Follow-up ticket.


What I could not reach

  • Did not confirm the replica sync path assigns asset_id/hint to a planted coin. G1 is proven on the fallback tier from the code cited; on the replica tier the SQL is unauthenticated, but whether a planted coin is classified as $DIG depends on sync-side logic I did not read. G1 holds on at least one live tier either way.
  • No on-chain confirmation that a coin planted at the CAT-wrapped hash is unspendable-but-scannable. It follows from CAT lineage rules and from this PR own threat model, but I did not execute it against a chain.
  • gitnexus not used — blast radius done with git grep plus direct reads rather than a stale index, per the brief and §2.0.
  • Only mirror::funding unit tests and the mirror_operator_funding integration test were run. No full-workspace suite, no clippy, no cargo fmt.

#469)

Three gate findings on #469, all the same class as the fix the PR was opened
for: a figure a stranger can choose, reaching an operator as a fact.

G1 -- the decision-shortfall path quoted `balance % per_coin`, derived from
`dig_balance_base_units`, the raw sum over the publicly derivable
`dig_cat_puzzle_hash(owner)`. Neither balance tier authenticates CAT lineage,
so planting one 499-mojo coin had the operator told they were 0.001 DIG short
when they were 0.500 short -- and the alert gate then suppressed the
correction as immaterial. SPEC 25.11, added by this same PR, forbids exactly
that. The pass is still classified SHORT (authentication only ever removes
candidates, so a reported balance below one create's cost proves the real one
is too); what it no longer does is quote an amount it never authenticated.
`FundingShortfall::have_dig_base_units` is REMOVED rather than left unused, so
no future caller can render it again.

G2 -- exhausting MAX_AUTHENTICATION_ATTEMPTS mapped to `Unknown`, on which the
gate returns None and even the `tracing::warn!` in `execute`, gated on the
alert being Some, never fired. A stranger burying the honest coins under 128
unauthenticatable ones stopped this node bonding on every pass, forever, and
the operator was never told. Refusing to quote a total is right; refusing to
speak is a different thing. The constant is unchanged.

Both now map to `FundingObservation::Unmeasured`, which alerts once on entry,
states no total and no deficit, and clears no live shortfall.

G3 -- corrected the false claim that one PASS costs at most
MAX_AUTHENTICATION_ATTEMPTS reads. The bound is per SELECTION and `create`
runs once per bond, so a pass planning K creates costs up to K x the budget.
Documented rather than changed; a per-pass shared budget is filed as
follow-up.

SPEC 25.11/25.12 updated to name the fourth observation and to require that a
blocked pass with no authenticated total is reported without an amount.

Co-Authored-By: Claude <noreply@anthropic.com>
…de#481

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

Copy link
Copy Markdown
Contributor Author

Response to the CHANGES-REQUIRED verdict — all three gating findings fixed

Head: db3ca04d94bec13eb25b7e3ae21df29ab8d74dda. Version held at 0.222.0. Still DRAFT.

Thank you for the mutation-verified confirmation of the ordering fix — it is untouched, and both its
tests still pass.

G1 (HIGH) — fixed in the CODE, not in the spec

runner.rs's None if decision_shortfall.is_some() arm no longer produces a Short. A Short
carries a spendable total, and the only total this arm ever had was dig_balance_base_units, which
neither balance tier authenticates. It now produces
FundingObservation::Unmeasured(NoCreateAffordable { need_dig_base_units }) — the requirement, which
is derived from the epoch and the plan and which no stranger can move.

The SHORT classification is kept, and deliberately: authentication only ever REMOVES candidates, so a
reported balance below one create's cost proves the authenticated one is below it too. It is the
AMOUNT that has no honest value on this path.

FundingShortfall::have_dig_base_units is REMOVED, not merely left unrendered. An unauthenticated
money figure that no caller happens to render is one refactor away from being rendered again; a struct
that cannot hold it cannot regress.

New test a_pass_that_authenticated_nothing_quotes_no_spendable_total. The fixture is your exploit,
not an empty wallet — the operator holds nothing, a stranger plants 999 base units against a
1,000 requirement. That distinction is load-bearing: at a balance of zero the defective version
renders "holds 0.000 DIG", which is true, and the test would pass under the defect.

Reverting only this fix (restoring balance % per_coin) fails it with the exploit verbatim:

the operator was told what their wallet can spend, off a total no candidate was authenticated for
Body was: ... the operator wallet holds 0.999 DIG that it can spend, so it is 0.001 DIG short ...

The test carries a control: the authenticated Insufficient path is asserted to still quote its
total, so an implementation that stripped every amount from every funding message fails.

G2 (HIGH) — the exhausted cap now says something true; the constant is unchanged

CandidatesUnverifiable maps to Unmeasured(AuthenticationTruncated { attempted, skipped }), which
alerts once on entry, clears nothing, and quotes no total. MAX_AUTHENTICATION_ATTEMPTS is still
128.

What the operator is told:

Your node cannot bond content: the operator address holds more coins than one pass may check, and
128 were checked with 128 of them not provably yours before the budget ran out. How much the wallet
can spend is UNKNOWN, not low, so no figure is given — and adding $DIG may not clear it.
Consolidate the operator wallet's own $DIG into fewer coins. Until then no new content is
collateralised and it earns nothing.

remedy is None for this case on purpose: TopUp is the wrong instruction because adding money
need not help, and Consolidate as a structured remedy asserts the wallet holds enough — which is
exactly what was not established.

Your composition is what the new test drives: pass 1 latches a real authenticated shortfall, pass 2
truncates, pass 3 repeats. Pass 2 must SPEAK (that is the correction that could never arrive) and pass
3 must be silent (144 messages a day is how an operator learns to ignore them). Reverting only the
mapping reds two tests:

a_truncated_authentication_walk_tells_the_operator_without_quoting_a_total ... FAILED
authentication_is_bounded_by_a_constant_however_many_coins_a_stranger_sends ... FAILED
  left: Unknown   right: Unmeasured(AuthenticationTruncated { attempted: 128, skipped: 128 })

The alert gate holds the unmeasured latch SEPARATELY from the short latch, because the two are not
alternatives — a wallet latched short that then becomes unmeasurable is exactly the transition an
operator must hear about. Healthy clears both, and both take()s run before the test, so ||
short-circuiting cannot leave one latch set.

G3 (MEDIUM) — comment corrected; the shape change is filed, not decided here

Your reading is right and the comment now states it: the bound is per SELECTION, create runs
once per bond, so a pass planning K creates costs up to K x 128 reads. The comment also records the
part that makes it more than a doc nit — the create loop's break-on-failure does not cover the case
where every create still SUCCEEDS while each pays a wasted walk.

I documented rather than changed it, because a per-PASS shared budget is a behaviour change, not a
counter change: exhausting a shared budget part-way turns later bonds into CandidatesUnverifiable,
which as of this PR speaks to the operator — so it needs a decision about what a
ran-out-of-budget pass tells someone, which is not mine to take inside a fix round. Filed as
#481, with that interaction stated.

D1 and D2 — filed, not fixed here

Both on #481 alongside the per-pass budget, since all three live in mirror/funding.rs and its
call sites.

Evidence

run result
full lib suite at db3ca04 732 passed; 0 failed; 0 filtered out
the two new tests by name 2 passed; 0 failed; 730 filtered out
G1 fix reverted 0 passed; 1 failed; 731 filtered out
G2 fix reverted 0 passed; 2 failed; 730 filtered out

Counts are quoted, not exit statuses. Each mutation was applied to a committed tree and restored
by cp from a file copy, never git checkout; git status --porcelain was empty after each restore
and HEAD was unchanged.

SPEC.md §25.11/§25.12 now name the fourth observation and require that a blocked pass with no
authenticated total is reported without an amount — so the contradiction you found is resolved by
the code moving to §25.11, with §25.12 widened to describe what the code now does rather than
narrowed to excuse what it did.

Not re-run: clippy, the full workspace suite, cargo fmt (per the lane brief).

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

Copy link
Copy Markdown
Contributor Author

Rustfmt was red on 1e8a4fe — the new CandidatesUnverifiable arm only. Fixed at 3fd69386f58a8fe30cecfc8cafe4972fd4bd6269 (scoped rustfmt on the three touched files, never --all); full lib suite re-run after the reformat: 732 passed; 0 failed; 0 filtered out. Head for review is now 3fd6938.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security round 2 — IN PROGRESS, not the verdict

Re-gating the scoped delta b88e522..3fd6938. Head 3fd69386f58a8fe30cecfc8cafe4972fd4bd6269, resolved from gh pr view 469 --json headRefOid myself. Delta is 4 files, +493/-66.

Verified so far by direct read

G1 — fixed, and fixed in the direction I asked for. FundingShortfall::have_dig_base_units is genuinely removed from the struct (pass.rs), not merely left unread — so the unauthenticated figure has no carrier, which is the right shape. decide no longer even destructures inputs.dig_balance_base_units for it (pass.rs, the match arm is now (Some(per_coin), Some(split))). The runner.rs arm emits Unmeasured(NoCreateAffordable { need_dig_base_units }). The code moved to the spec rather than the spec to the code, as claimed.

The latch short-circuit is genuinely handled. clear_and_announce_recovery (funding.rs:770-782) runs BOTH take()s into locals before testing was_short || was_unmeasured, and the comment names the exact hazard. I checked the other direction too: the Unmeasured arm (funding.rs:790-796) does not touch self.alerted, so a latched short that becomes unmeasurable does speak, and does not clear the short latch.

G2 — the exhausted cap speaks. CandidatesUnverifiable now maps to Unmeasured(AuthenticationTruncated { attempted, skipped }) (funding.rs:704-711), alerting once per entry, quoting no total, clearing nothing. Constant untouched at 128.

Correction to the round-2 brief (not a defect)

The brief says "remedy is None deliberately" for the unmeasured case. That is true only for AuthenticationTruncated. NoCreateAffordable carries Some(FundingRemedy::TopUp) (funding.rs:883-886). The code's split is well reasoned and better documented than the summary; flagging only so the verdict is not read against the wrong claim.

Still open

  • The remedy: None ambiguity the lane flagged — ruling on it.
  • Two doc-accuracy items I am still weighing on UnmeasuredFunding::NoCreateAffordable.
  • Whether deferring G3 to dig-node#481 was the right call.
  • My own re-run of the revert-proofs, building now.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security round 2 — IN PROGRESS: my own mutation found a coverage gap

Head 3fd69386f58a8fe30cecfc8cafe4972fd4bd6269. Own worktree, mutations on a committed tree, restored by cp.

Baseline reproduced independently: 732 passed; 0 failed; 0 filtered out — matches the claim.

The latch short-circuit is CORRECT but NOTHING TESTS IT

The brief asked me to verify the two latches are independent, and said "that short-circuit is the kind of thing that works until it doesn't." It is right on both counts, and the second half is the finding.

I replaced clear_and_announce_recovery (funding.rs:770-782) with exactly the short-circuit form the lane says it avoided:

(self.alerted.take().is_some() || self.unmeasured.take().is_some()).then(|| FundingAlert {

Full library suite under that mutation: 732 passed; 0 failed; 0 filtered out. The regression is invisible to every test in the crate.

The behaviour it breaks: with alerted set, || short-circuits, unmeasured is never taken and stays latched — so after a recovery the next Unmeasured alert about the same reason is swallowed, and a node that stops bonding again goes unreported.

The reason nothing catches it: a_truncated_authentication_walk_tells_the_operator_without_quoting_a_total (runner.rs:1093) covers short-then-truncated, which is the direction that needs BOTH latches set. Nothing covers short + unmeasured -> recovery -> unmeasured again, which is the only sequence the short-circuit changes. The funding.rs unit-test set is byte-for-byte the same 18 tests as round 1 — no gate test was added for the new latch at all.

Ruling: defense-in-depth, LOW — do NOT gate. The code at this head is correct; what is missing is the test that keeps it correct. Recommend the four-observation sequence above as a FundingAlertGate unit test, on #481.

Also verified

  • The removal is structural in a stronger way than claimed: execute has no access to dig_balance_base_units at all (it takes decision, current_epoch, locked_dig_base_units), so once the field left FundingShortfall the unauthenticated figure became unreachable from the arm that used to render it. A revert genuinely needs pass.rs changes too — I had to make three of them to reproduce it.
  • SPEC §25.11/§25.12 no longer contradict each other. §25.12 now says outright: "A short observation's spendable total MUST be authenticated (§25.11). A pass that has no authenticated total is unmeasured, never short with the address total." That was the specific contradiction I gated on.
  • unmeasured_alert's truncated body says "adding $DIG may not clear it" and carries remedy: None, matching SPEC's new "a truncated walk MUST NOT tell an operator to add $DIG".

Running my own G1 revert now, then the verdict.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security round 2: PASS

Head audited: 3fd69386f58a8fe30cecfc8cafe4972fd4bd6269 — resolved from gh pr view 469 --json headRefOid myself, at the start and again at the end; unchanged throughout. Scoped delta b88e522..3fd6938, 4 files, +493/-66. Version held at 0.222.0.

All three gating findings are fixed. Everything below is defense-in-depth and must not gate.

Evidence I produced myself

Own detached worktree, mutations applied to a committed tree and restored by cp, never git checkout. Counts read, not exit status — every cargo | tail in my logs reports PIPE_RC=0 even on a compile error, because that is the pipeline status and not cargo.

run result
baseline at 3fd6938 732 passed; 0 failed; 0 filtered out
G1 reverted (3 hunks across pass.rs + runner.rs) 731 passed; 1 failed
recovery latch short-circuited 732 passed; 0 failed — see D3

The G1 revert reproduces my round-1 exploit verbatim in the failure output:

the operator wallet holds 0.999 DIG that it can spend, so it is 0.001 DIG short.
Add $DIG to the operator wallet.

G1 — FIXED, and more structurally than claimed

FundingShortfall::have_dig_base_units is removed from the struct, not merely left unread. The stronger property, which I checked rather than took on trust: execute never receives the balance at all — its parameters are decision, current_epoch, locked_dig_base_units — so once the field left the struct the unauthenticated figure became unreachable from the arm that used to render it. My revert needed three separate hunks in pass.rs before runner.rs could be made to emit it, and a fourth before it compiled. That is the cannot-regress shape the doc claims.

The fixture choice is load-bearing exactly as stated: at balance 0 the defective version renders "holds 0.000 DIG", which is true, so a zero-balance fixture passes under the defect. The 999-against-1,000 fixture is what makes it red. The control asserting the authenticated Insufficient path still DOES say "that it can spend" is present (runner.rs:1064-1069) and defeats blanket amount-stripping.

G2 — FIXED, and the latch independence holds in both directions

CandidatesUnverifiable maps to Unmeasured(AuthenticationTruncated { attempted, skipped }) (funding.rs:704-711), alerts once per entry, quotes no total, clears nothing.

Both directions checked:

  • clear_and_announce_recovery (funding.rs:770-782) runs both takes into locals before testing was_short || was_unmeasured. Correct.
  • the Unmeasured arm (funding.rs:790-796) does not touch self.alerted, so a latched short that becomes unmeasurable speaks, and does not clear the short latch.

SPEC §25.11 and §25.12 no longer contradict each other — §25.12 now states outright that a pass with no authenticated total is unmeasured, never short with the address total. That contradiction was the substance of my G1 gate.

G3 — deferral to #481 ACCEPTED

My complaint was the false doc claim, and it is corrected: the doc now states the per-selection bound, the K x 128 per-pass cost, and the plant-127 case where every create still succeeds so nothing breaks.

The deferral reasoning is sound and I would have made the same call. A per-pass shared budget is a behaviour change, not a counter change: exhausting it part-way turns later bonds into CandidatesUnverifiable, which as of this PR speaks to the operator — so it needs a decision about what a ran-out-of-budget pass says, and that is a design fork, not a re-gate fix. K is the node own bond count, not an attacker choice, so the amplification factor is bounded by node configuration rather than by a stranger.

Ruling on the remedy: None ambiguity — real, LOW, and it SHOULD be filed

remedy: None now means both recovery and no remedy established. My ruling: not gating, but the reason given for not filing it does not hold.

"No such caller exists today" is the same reasoning this PR rejected one layer down. PassError::Funding was introduced precisely so a consumer would not have to recover a classification from prose, and its doc says flattening it "would mean the only surface that can tell an operator what to DO about it would have to recover the classification by matching on prose". A None that means two opposite things is that shape. Today the two cases differ only in title and body text, so a future programmatic consumer must string-match to tell a recovery from a blocked pass.

It does not gate because the only consumer today is the tracing::warn! at runner.rs:499-505 and the tests, and a human reading either message cannot confuse them. Fold onto #481.

One correction for the record: the brief says remedy is None for the unmeasured case. That is true only for AuthenticationTruncated. NoCreateAffordable carries Some(TopUp) (funding.rs:883-886), which is the right call and matches the new SPEC prohibition on telling a truncated walk to add $DIG.

D3 — NEW, defense-in-depth, LOW: the recovery latch is correct but untested

The brief called the short-circuit "the kind of thing that works until it doesn't". It is right, and the second half is the finding. I replaced clear_and_announce_recovery with exactly the form the lane avoided:

(self.alerted.take().is_some() || self.unmeasured.take().is_some()).then(|| FundingAlert {

Full library suite under that mutation: 732 passed; 0 failed. The regression is invisible to every test in the crate.

a_truncated_authentication_walk_tells_the_operator_without_quoting_a_total covers short-then-truncated, which needs both latches SET. Nothing covers short + unmeasured, then recovery, then unmeasured again — the only sequence the short-circuit changes. The funding.rs unit-test set is the same 18 tests as round 1, so no gate test was added for the new latch at all. Recommend that four-step sequence as a FundingAlertGate unit test, on #481.

D4 — NEW, defense-in-depth, LOW: two inaccurate claims on NoCreateAffordable::need_dig_base_units

The field doc reads "What one create needs... Derived from the epoch requirement and the plan, never from the wallet, so it is a figure no stranger can move". Both halves are inaccurate:

  • it is assigned split.shortfall_dig_base_units, which is (short.len() as u64) * per_coin (plan.rs:242) — the total cost of ALL unmade creates, not one create cost;
  • short.len() comes from affordable_count = balance / per_coin (plan.rs:234-238), so it is derived from the wallet balance.

The behaviour is nonetheless correct, and I worked through why. Inflating the balance moves creates from short into affordable; those are then attempted, fail at authentication, and set stopped_at, so this arm is not reached. It is reached only when affordable is empty — where inflation below one create cost changes short.len() not at all — or when every affordable create genuinely succeeded on authenticated money. So the figure is not attacker-movable in the reachable states.

But the safety rests on the create-loop break at runner.rs:427, which is not what the comment says it rests on. A future change to that loop would silently invalidate a justification nobody would re-derive, because the comment says the question does not arise. Correct the comment on #481.

What I could not reach

  • Did not re-examine the replica sync path asset_id/hint assignment (round-1 caveat, unchanged). It no longer bears on G1, since no balance-derived figure reaches an operator on this path at all.
  • Ran cargo test -p dig-node-service --lib only this round — no integration tests, no clippy, no cargo fmt --check.
  • Did not re-run the round-1 mirror_operator_funding integration suite against this head.
  • gitnexus not used; blast radius via git grep and direct reads.

Not merged, not undrafted. My worktree is removed and the primary checkout was never touched.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 1, 2026 15:42
@MichaelTaylor3d
MichaelTaylor3d merged commit ff62f55 into main Sep 1, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/mc-fund branch September 1, 2026 15: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

1 participant