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.
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_ATTEMPTSbounds a SELECTION, not a passcrates/dig-node-service/src/mirror/funding.rs— the constant bounds one call toselect_operator_dig_cats, andNodeMirrorEffects::createis called once per bond(
lifecycle.rs:426-460), so a pass planning K creates costs up to K x 128 chain reads undertokio::task::block_in_place.The create loop breaks on the first FAILURE (
runner.rs), which bounds a pass that cannot funditself to one selection — but not the case that matters: a stranger who plants
MAX_AUTHENTICATION_ATTEMPTS - 1coins ranked above the honest ones leaves every create stillSUCCEEDING, 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 designquestion 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.
skippedis discarded at the only production call sitefunding.rsdocuments that "a skip is counted and reported, never swallowed". In production it isswallowed:
lifecycle.rs:454callsselect_operator_dig_cats, whose body is.map(|selection| selection.cats), so theskippedvector reaches tests only.The production record is the per-candidate
tracing::warn!insideauthenticate— up to 128 linesper 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::AuthenticationTruncatedcarries) or make the comment true, and rate-limit the per-candidate line.
3. Two rival
FundingObservationtypescrate::wallet_funded::FundingObservation(pre-existing, consumed atserver.rs:2796) andcrate::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.rsand its call sites, so one lane opening that fileshould take all three.