Skip to content

mirror funding: per-PASS authentication budget, the discarded skip count, and the rival FundingObservation #481

Description

@MichaelTaylor3d

Task

Three defense-in-depth follow-ups split out of the dig-node#469 gate round, so #469 could close on its
three gating money-honesty findings. None is user-visible today; all three are real.

Parent orchestrator epic: https://github.com/DIG-Network/dig_ecosystem/issues/3124

1. MAX_AUTHENTICATION_ATTEMPTS bounds a SELECTION, not a pass

crates/dig-node-service/src/mirror/funding.rs — the constant bounds one call to
select_operator_dig_cats, and NodeMirrorEffects::create is called once per bond
(lifecycle.rs:426-460), so a pass planning K creates costs up to K x 128 chain reads under
tokio::task::block_in_place.

The create loop breaks on the first FAILURE (runner.rs), which bounds a pass that cannot fund
itself to one selection — but not the case that matters: a stranger who plants
MAX_AUTHENTICATION_ATTEMPTS - 1 coins ranked above the honest ones leaves every create still
SUCCEEDING, so nothing breaks, while each pays the full wasted walk, on the ten-minute timer,
indefinitely, off a one-time dust spend.

#469 corrected the doc comment to state the real bound rather than change the shape. The fix is a
per-PASS budget shared across the create loop. Note the interaction that makes it more than a
counter: exhausting a shared budget part-way turns later bonds into
FundingError::CandidatesUnverifiable, which since #469 SPEAKS to the operator — so the design
question is what the operator is told when the pass ran out of budget rather than out of money.

Bounded factor (K is the node's own bond count, not an attacker's choice), which is why it is
follow-up and not gating.

2. skipped is discarded at the only production call site

funding.rs documents that "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), so the skipped vector reaches tests only.

The production record is the per-candidate tracing::warn! inside authenticate — up to 128 lines
per selection per create per pass, roughly 18k lines/day/create, and the volume is attacker-driven.
Either surface the count (it is now exactly the figure UnmeasuredFunding::AuthenticationTruncated
carries) or make the comment true, and rate-limit the per-candidate line.

3. Two rival FundingObservation types

crate::wallet_funded::FundingObservation (pre-existing, consumed at server.rs:2796) and
crate::mirror::funding::FundingObservation (added by #463/#469). Same name, same problem domain,
different semantics — CLAUDE.md §2.0 centralize-rivals. Name the survivor; they are not obviously
the same concept, so renaming one may be the whole fix.

Evidence

All three found by the loop-security gate on #469 and
recorded in its verdict comment. Items 2 and 3 were explicitly rated LOW / do-not-gate there.

Scope

Item 1 is a design change and should be sized first; 2 and 3 are small. They are filed together
because they all live in mirror/funding.rs and its call sites, so one lane opening that file
should take all three.

Metadata

Metadata

Assignees

No one assigned

    Labels

    kind:maintenancereal work that changes nothing a user can perceive

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions