Skip to content

feat(mirror): verify a peer's mirror-coin bond against chain on the download path - #467

Merged
MichaelTaylor3d merged 21 commits into
mainfrom
loop/mc-verify
Sep 2, 2026
Merged

feat(mirror): verify a peer's mirror-coin bond against chain on the download path#467
MichaelTaylor3d merged 21 commits into
mainfrom
loop/mc-verify

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Refs #466 -- deliberately NOT a closing keyword. See "Why #466 stays open" below.

Verifies a peer's claimed mirror-coin bond against chain, and acts on the verdict by RANKING the
holder set — the first consumer of MirrorCoin::advertises outside dig-mirror-coin itself.

Read this first: the layer is INERT today, by construction, and that is deliberate

Bonded is unreachable on a real node right now, so this PR changes no observable behaviour.
It is not a flag, a TODO, or an oversight — it is the only safe posture until
#473 lands, and the code is built so that resolving
473 turns it on with no second change here.

claim true today?
a correct verifier exists, proven by revert probes yes
it is installed on a production path (NodeContent::new) yes
it detects a false claim on a real node noverdict_for returns Unverified before any chain read
the collateral is enforced end to end no
it costs anything today (chain reads, reordering, egress) no — zero, on every input
turning it on is a one-function-body change yes, and the gate lifts itself

Why: a coin proves that a bond exists, never that the claimant holds it. Promoting on the chain
half alone is the HIGH finding from the first security round — a stranger reads an honest holder's
coin id from the DHT (it is published in cleartext by design) and republishes it to rank first at
zero collateral. Nothing in this repo can currently bind a mirror coin's owner to a DHT peer id, so
the layer withholds credit from everyone rather than granting it on a check it cannot make.

declaration_source_is_readable() probes through the real peer_declaration, so the moment
dig-mirror-coin 0.8.0 exposes the typed dig-peer: accessor and that body is replaced, both
short-circuits lift automatically. No flag to remember, and
no_visible_term_promotes_a_claim_before_the_typed_accessor_exists FAILS when it happens, so the
obligations that must ship in that same change cannot be forgotten.

What it does

  1. Chain half (crates/dig-node-service/src/mirror/bond_verify.rs) — SYSTEM.md's algorithm,
    not an invented one: coin record at mirror_coin_puzzle_hash(), re-derived from its creating
    spend so asset id / amount / owner all come from executed on-chain code, then
    MirrorCoin::advertises for exact equality on the memo-declared triple with the hint
    recomputed from the coin's own lineage proof. Never an arithmetic recompute of the morph alone —
    mirror_hint sums four terms including an unbounded freely-chosen epoch, so an author can
    solve onto another's hint.
  2. Ownership half — does the coin declare the peer offering the record. Gated off (above).
  3. Decision layer (crates/dig-node-core/src/mirror_bond.rs) — BondVerdict and
    BondRankingLocator, wrapped outside every other locator layer in NodeContent::new, so the
    multi-source download, the redirect-on-miss hint and the capsule warm inherit one ranking.

Credit-only ranking — a stranger can never demote an honest peer

Two tiers, not three. Bonded promotes; absent / Unverified / Unbonded collapse to ONE baseline
tier with source order preserved (sort_by_key is stable). BondVerdict deliberately does not
derive Ord, so credit_rank is the sole ordering and a future .sort() on the verdict cannot
compile.

This is what makes hearsay safe to act on. A provider record is attributed by nobody — dig-dht
says so itself — so a bogus coin id can be attached to an honest holder's peer id by any peer that
answers a lookup. Under a three-tier lattice that sinks the honest node to last on every read.
Credit-only means an unverified or disproven pointer can never rank a peer below where no pointer
would have put it.
Hearsay can withhold credit; it can never subtract it.

Bounded work

  • MAX_VERIFIED_PER_LOCATE = 8, enforced in the production loop and asserted on the artifact by a
    test that drives the real BondRankingLocator with a 40-record slate and reads the counter.
  • Verdict cache keyed on (coin id, store, root, epoch, claiming peer id) — the claimant is part of
    the key because the verdict depends on it; a peer-agnostic key would serve one holder's earned
    Bonded to a stranger republishing the same public coin id.
  • Overflow evicts one entry, never clear() — clearing hands a stranger a cheap way to discard
    every honest verdict this node has earned by rotating coin ids.
  • Only definite verdicts are cached; Unverified records a momentary inability to look and holding
    it would keep an outage in force after it ended.

Where the acceptance tests sit, and why it matters

The acceptance tests call chain_bond_verdict, one level below verdict_for's short-circuit.
Through verdict_for today every one of them would return Unverified and be indistinguishable
from a broken verifier, so the control would be meaningless. This is stated rather than hidden: it
means the ticket's "detected on a production path" is proven of the mechanism and of its wiring, and
is not yet true of a running node. That gap is #473 and nothing else.

chain_bond_verdict is pub for exactly this reason and has no production caller.

Relationship to #473 — distinct defects, strictly sequenced

Measured, not assumed: dig-mirror-coin latest published is 0.7.0; there is no 0.8.0 and no PR
in flight for the accessor.

They are not the same defect and this is not duplicated work, but #466 delivers nothing observable
until #473 lands.
Sequence #473 next or this ships as a correct, free, silent no-op.

Why #466 stays open

#466's acceptance is that a peer advertising a bond it does not hold is detected on a production
path
, and its scope says "the acceptance is a call site, not a function." On a production path
today verdict_for returns Unverified for every input, so no such peer is detected. The mechanism
and its wiring are delivered; the detection is not.

Re-scoping the ticket to match what was built was the alternative, and it is the wrong one — that
acceptance was worded specifically to forbid shipping an unreachable verifier, so rewriting it after
the fact would defeat its own guard. #466 therefore stays OPEN with one exact, checkable resume
condition: #473 lands, peer_declaration gains a body, and the existing tests go green at
verdict_for level rather than at chain_bond_verdict level.
#473 closes both.

The adversarial gate reached this independently and gated on it; the correctness gate offered it as
one of two remedies. This is the one taken.

Evidence

Test counts are stated because a filter matching nothing exits 0 printing running 0 tests.
See the comment thread for the per-run counts and the revert probes on this head.

§2.4b dependencies

dig-* at latest published (dig-sex 0.5, dig-mirror-coin 0.7, dig-nat 0.21,
dig-identity 0.7.1, dig-constants 0.13.0). chia-* deliberately held at the 0.36.1 / sdk
0.36.0 line: dig-mirror-coin 0.7 and chia-query compile against it, and moving chia here alone
would ship this crate split across two chia lines — the exact defect 2.4b exists to prevent. The
0.48 uplift is a release-first cascade owned upstream.

2026-09-02T01:45Z orchestrator: removed the trailing Closes #466 per the loop-decider verdict (acceptance "detected on a production path" is unmet while the layer is inert); #466 stays open and tracks activation.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Audited head: 3b978d9618119cf418df4c70b5d6c5d23830ee06 (resolved from gh pr view 467 --json headRefOid; merge-base 95c9582).

Posting as I go so nothing is lost to a stall. This is not the verdict.

Finding 1 (LEAD) — Bonded proves the coin exists, not that the claiming peer owns it. A stranger gets top rank for free.

crates/dig-node-service/src/mirror/bond_verify.rs:105 (verdict_for) and the trait it implements,
crates/dig-node-core/src/mirror_bond.rs:78 (MirrorBondVerifier::verify), take only (content, claimed_coin_id).
The claiming peer is never an input. BondRankingLocator::find_providers
(crates/dig-node-core/src/mirror_bond.rs:120) has record.provider_peer_id in hand and passes only
record.unverified_mirror_coin_id_bytes().

So the four steps establish a valid mirror coin bonding this (store, root, epoch) exists. Nothing
establishes this holder created it. MirrorCoin::advertises derives the owner from the coin's own lineage
proof (dig-mirror-coin-0.7.0/src/coin.rs:93,148) — correct for "does this coin bond this tuple", and it is
the sound test for that question — but the owner is compared only against the coin's own hint, never against
the peer making the claim.

Exploit, concretely. State: honest holder H publishes a provider record for capsule (S,R) carrying its
real mirror-coin id — dig-node already does this on main, crates/dig-node-core/src/seams/dig_peer/dht.rs:494
and :618 call announce_provider_with_collateral(id, coin_id). Attacker action: A calls find_providers for
(S,R) (the same public DHT query every downloading node makes), reads H's unverified_mirror_coin_id in
cleartext off the record, and publishes its own provider record for (S,R) carrying that same coin id.
Impact: every verifying node reads the coin, all four steps pass, A is ranked Bonded — tied with H and
above every honest holder that has no pointer. Zero collateral, zero chain writes, one public read.

A second, independent source of the same id needs no DHT at all: every mirror coin in existence shares one
puzzle hash (dig-mirror-coin-0.7.0/src/asset.rs:29mirror_coin_puzzle_hash() is a global constant), so
get_coin_records_by_puzzle_hash(mirror_coin_puzzle_hash()) enumerates all of them and the declared
(store, root, epoch) is in the memos.

Why this is not merely "the guarantee is weaker than hoped". The sort key is now primary
(mirror_bond.rs:143, sort_by_key), and #836's deliberate connection-verified-pool-first order survives only
within a verdict class. So a strange peer holding a copied 32 bytes is promoted above connection-verified
honest peers. Before this PR no remote party could deterministically buy top rank; after it, one public read
buys it. That is a new free promotion primitive for an unbonded stranger, and it is the exact inverse of what
#466 set out to do.

Severity HIGH. Not CRITICAL: content is still merkle-verified so this is rank/traffic capture, not content
forgery. But it is a live, cheap, remote capability that this PR creates.

The honest part: SPEC.md §25.6a's table says "the named coin passes every §25.6 check", which is literally
true and does not overclaim ownership. The gap is in the mechanism, not in the prose.

Still working: cache eviction/staleness (3), the three failure directions by mutation (2), the pub widening
(4), the acceptance test's real-constructor claim (5), and the declared hint-scan limitation.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

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

Head still 3b978d9.

Finding 2 — the "cheap lookup" budget now buys up to ~170x its calibrated cost. Remote chain-source amplification.

The per-requestor miss limiter runs in the right PLACE but is now sized for the wrong WORK.

crates/dig-node-core/src/download.rs:2603 admits one miss -> find_providers per token, and its own
comment at :2612 calls it "the cheap DHT lookup the (1) budget is sized for". The constant agrees:
crates/dig-node-core/src/rate_limit.rs:165 DEFAULT_MISS_LOOKUP_BURST = 16.0, :169 refill 4.0/sec,
with the doc at :161 justifying the size because "a miss lookup is cheaper ... than an identity ping" —
and the genuinely expensive legs (proxy fetch :179, relay ask :200) each get a QUARTER of it precisely
because they are not cheap.

This PR makes that same admitted lookup perform, per located record, up to two blocking chain RPCs
(crates/dig-node-service/src/mirror/bond_verify.rs:112 coin_record, :141 coin_spend), driven from a
sequential loop at crates/dig-node-core/src/mirror_bond.rs:131-142. Nothing in the PR adds a bound of its
own, and the limiter was not resized.

How big the slate is, and who chooses it. dig-dht-0.15.0/src/lookup.rs:45
MAX_PROVIDERS_PER_RESPONSE = 64, and find_providers runs with stop_on_providers = true
(src/lookup.rs:185) — the walk ENDS on the first peer that answers with any provider. So a single
malicious responder deterministically supplies the whole slate: 64 fabricated records, 64 distinct
fabricated coin ids. Plus up to 20 local (src/provider_store.rs:54), deduped by peer id.

The arithmetic. One admitted miss -> up to ~84 records -> up to ~84 coin_record reads (a
nonexistent coin id costs exactly one: bond_verify.rs:115 returns Unbonded on Ok(None)). Sustained
per requestor at 4 tokens/sec: ~340 chain RPCs/sec, from one remote source, against this node's chain
source. Bytes-in: one redirect request. Work-out: hundreds of upstream reads.

The memoisation does not save it, and the attacker controls that too. bond_verify.rs:76
MAX_CACHED_VERDICTS = 1024, and overflow is cache.clear() (:200) — a full wipe, not an eviction. An
attacker rotating >1024 distinct coin ids thrashes the cache to empty, so every locate re-reads, AND every
honest Bonded verdict is discarded along with it. The key is (coin_id, store, root, epoch) with
coin_id supplied by a stranger (:88), so filling it is free.

Latency, on top of the read cost. The loop is sequential and each read is
tokio::task::block_in_place (bond_verify.rs:263), so one locate can hold a tokio worker for
N x RTT — ~13 s at 84 records and 150 ms RTT — on the redirect path a stranger reaches.

Severity HIGH. This is the amplification shape, not a perf note: a small remote request elicits large
upstream work, the limiter in front of it was calibrated against a cost this PR multiplied, and the
attacker picks both the record count and the ids.

Finding 3 (MEDIUM) — a per-record disk read+parse that the cache cannot absorb

bond_verify.rs:281 calls epoch_and_requirement() BEFORE the cache lookup at :288. That helper
(:227) calls requirement(&EpochRecordStore::in_state_dir(), ...), and EpochRecordStore::get
(crates/dig-node-service/src/collateral.rs:647-667) does a full std::fs::read_to_string plus a
line-by-line serde_json parse of the append-only epoch file — on every record, on every locate, even
on a cache hit
. It is also plain blocking I/O directly on the async worker, with no block_in_place or
spawn_blocking around it. Moving the cache probe ahead of it costs nothing and removes the whole class.

Still to do: mutation spot-check of the three failure directions, the pub(crate) -> pub widening, and
whether the acceptance test really drives the production constructor.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Correction to the line citations in my previous comment

Some file:line refs in the comment above were transcribed from a working copy and are wrong. A verdict
whose citations do not resolve is not checkable, so here is the corrected set at 3b978d9. The findings
themselves are unchanged; only the numbers move.

crates/dig-node-service/src/mirror/bond_verify.rs:

claim I wrote correct
VERDICT_TTL (600 s) 52
MAX_CACHED_VERDICTS = 1024 76 60
coin_id is the attacker-supplied key field 88 69
source.coin_record(...) — chain read 1 112 92
Ok(None) => Unbonded (a nonexistent coin costs exactly one read) 115 95
source.coin_spend(...) — chain read 2 141 116
cache.len() >= MAX -> cache.clear() (full wipe) 200 178-179
fn epoch_and_requirement 227 201
epoch_and_requirement() called BEFORE the cache probe 281 228
the cache probe 288 238
tokio::task::block_in_place 263 254

crates/dig-node-core/src/mirror_bond.rs:

claim I wrote correct
MirrorBondVerifier::verify takes only (content, claimed_coin_id) 78 72
BondRankingLocator 120 94
the sequential per-record verify loop 131-142 122-124
sort_by_key — the verdict is the PRIMARY sort key 143 140

Correct as written, re-checked: crates/dig-node-core/src/download.rs:2603 (limiter) and :2613 ("the
cheap DHT lookup the (1) budget is sized for"); crates/dig-node-core/src/rate_limit.rs:165 / :169 /
:179 / :200; crates/dig-node-core/src/seams/dig_peer/dht.rs:494 and :618;
crates/dig-node-service/src/collateral.rs:647; dig-mirror-coin-0.7.0/src/coin.rs:93,148 and
src/asset.rs:29; dig-dht-0.15.0/src/lookup.rs:45,185 and src/provider_store.rs:54.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

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

Head still 3b978d9. Line numbers below are verified against that tree.

Check 4 — the pub(crate) -> pub widening: CLEAR, no finding

crates/dig-node-core/src/download.rs:2550 (Node::p2p_content) widens correctly and narrowly.

  • dig-node-core is not publishedindex.crates.io/di/g-/dig-node-core returns NoSuchKey, and every
    consumer is a path dep in this same repo (dig-node-service, dig-runtime, dig-wallet). So this is a
    workspace-internal widening, not a crates.io API change.
  • The returned type was already pub (pub struct NodeContent, download.rs:746), so no new type is
    exposed — only the ability to obtain the node's live instance from a &Node.
  • Exactly one out-of-crate caller exists: crates/dig-node-service/src/mirror/bond_verify.rs:282. Every
    other call site is inside dig-node-core (download.rs:2563, :2596; lib.rs:3883, :4541;
    seams/capsule/capsule_store.rs:566; seams/content/bandwidth.rs:199;
    seams/content/content_serve.rs:1025; seams/dig_peer/module_relay.rs:116) plus tests.
  • NodeContent carries 15 pub methods, all of which a consumer could already name; the 0.65.0 -> 0.66.0
    minor bump is the right call under 0.x for an additive surface change.

Check 5 — the acceptance test does drive the real constructor: CONFIRMED

the_engine_ranks_a_disproven_bond_last_on_its_own_discovery_path (download.rs:4413) builds via
NodeContent::new, and that is genuinely the production path:

  • NodeContent::for_dht — the production constructor (download.rs:1501) — assembles
    provider_locator_chain and then calls Self::new at download.rs:1523.
  • NodeContent::new (:1249) wraps whatever locator it is handed in BondRankingLocator at the top of the
    body, so the layer is outermost over the union / self-excluding / capsule-fallback chain in production and
    over the mock in the test alike.

So a wiring change that dropped the layer fails this test with every mirror_bond unit test still green.
The claim in the brief holds.

But one level up is untested, and it is the same failure class (MEDIUM)

The test proves the layer is reachable; nothing proves the verifier is ever installed.
spawn_bond_verifier_install (bond_verify.rs:277) is gated on peer_network_enabled() and
config.enable_chain_sync (crates/dig-node-service/src/server.rs:2164), then polls node.p2p_content()
60 times at 2 s (:298-300) and gives up after 120 s. If the engine is not up inside that window — a
slow bring-up, a large inventory announce — the verifier is never installed, the locator stays a
pass-through forever, and the only trace is a tracing::debug! (:303), not a warning. A node in that
state is indistinguishable from a working one on every surface.

Cache coverage (extends Finding 2): ZERO tests

ChainBondVerifier — the 600 s TTL, the 1024-entry bound, the clear-on-overflow, and
epoch_and_requirement — has no test anywhere in the repo. Every test in
crates/dig-node-service/tests/mirror_bond_verify.rs drives the pure verdict_for directly, and
grep -n "ChainBondVerifier\|MAX_CACHED_VERDICTS\|VERDICT_TTL" outside bond_verify.rs itself returns
nothing. The brief called the cache attacker-adjacent; it is also the one part of this PR that nothing
exercises.

The verdict_for tests themselves look sound rather than vacuous — in particular
a_declaration_that_disagrees_with_its_own_hint_is_unbonded (tests/mirror_bond_verify.rs:342) constructs a
coin whose declared tuple is right and whose hint is solved for a different epoch, which is exactly the
one-sided case that would pass if either half of advertises were dropped. Mutation spot-check still
running; I will report the result or say plainly that I could not reach it.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — Finding 4, and it is the one that decides the gate (still IN PROGRESS)

Head 3b978d9.

Finding 4 (HIGH) — the coin id is unauthenticated hearsay about a THIRD PARTY, so the ranking is steerable in both directions

The PR's central safety argument is that reordering is a safe primitive because it "drops nothing": a lying
publisher is served last, "an honest one that cannot prove itself loses nothing"
(crates/dig-node-core/src/mirror_bond.rs:26-32, and SPEC.md §25.6a "A holder is never refused, dropped,
or blocklisted"). That argument assumes the claim is made by the holder it is about. It is not.

dig-dht-0.15.0/src/service.rs:682 states it plainly about lookup-collected records: "A record collected
during a lookup was attributed by NOBODY: an arbitrary peer"
, and :778 "ProviderRecord carries no
signature". Only records that arrived over mTLS from the announcing peer, or through
ingest_verified_provider with a checked holder signature, are authoritative. Everything a lookup returns is
hearsay — and find_providers hands both kinds to the caller merged
(src/service.rs:243, merge_dedup_by_provider at :891).

BondRankingLocator (crates/dig-node-core/src/mirror_bond.rs:122-124) reads
record.unverified_mirror_coin_id_bytes() off whatever record it is given, with no check that the record was
authoritative, and ranks the peer named in provider_peer_id by the answer.

Exploit. An attacker positioned to answer one find_providers lookup — and it only needs to answer one,
because stop_on_providers = true ends the walk at the first peer returning any provider
(dig-dht-0.15.0/src/lookup.rs:185) — returns a slate of up to 64 records (:45) in which it:

  1. names honest holder H, with H's real addresses, and attaches a bogus coin id. The victim reads the
    chain, gets Unbonded (bond_verify.rs:95), and ranks H last on every read; and
  2. names itself, attaching a real coin id copied per Finding 1. It is ranked first.

Result: the attacker sets the rank of every holder in the slate, in both directions, for the cost of
answering one lookup. No collateral, no forged signature, nothing to break.

Why the existing hearsay mitigation does not cover this. dig-dht-0.15.0/src/service.rs:255 justifies
using hearsay on the fetch path: "a wrong candidate is merely a wasted dial because the merkle bind catches
it
". That is true of addresses. It is not true of the coin id, because the coin id is never fetched
against — it is used to form a judgement about a named third party and then act on it. The same doc says
what happens when hearsay is used that way: "it becomes THIS NODE'S claim about the world". This PR is the
first consumer to cross that line.

So the answer to "is reordering enough for a first implementation?" is not that it is too weak. It is the
wrong primitive while its input is unauthenticated.
A demotion that any stranger can aim at any honest peer
is a censorship primitive, and it is strictly worse than the pre-PR state where the field was inert.

The two fixes are different, and both are needed

  • Against Finding 4 (demotion): only act on the pointer when it came from an AUTHORITATIVE record — the
    mTLS-verified announcement or a signature-checked ingest_verified_provider. A hearsay record's pointer
    must be treated as absent (Unverified), which is already the safe default this PR implements for a
    missing pointer.
  • Against Finding 1 (free promotion): bind the coin's owner_puzzle_hash() to the claiming peer's
    identity. mTLS alone does not do this — it proves the record came from that peer, not that the peer owns
    the coin.

This reframes the limitation the lane declared. The missing peer_id -> owner_puzzle_hash mapping is not
merely why the hint-scan fallback could not be initiated. It is the reason the verification cannot be bound
to the claimant at all — which is the root of Findings 1 and 4 both. The lane was right that the mapping does
not exist and right to say so rather than fake it; the consequence is larger than the fallback.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — VERDICT: CHANGES-REQUIRED

Audited head: 3b978d9618119cf418df4c70b5d6c5d23830ee06 (resolved from gh pr view 467 --json headRefOid;
merge-base 95c9582; main at 6e2475a, so a rebase is owed and the head may move — re-run the two probes
below if it does).

Method: grep + direct read. gitnexus was NOT used — the dig-node index is ~301 commits stale and impact
returns a false-safe zero on a stale index. Two mutation probes ran in a lane-private worktree
(C:\tmp\worktrees\secgate-dn467, since removed); the primary checkout was never touched.


Mutation evidence (checks 1 and 2, both confirmed)

Baseline cargo test -p dig-node-service --test mirror_bond_verify: 9 tests, 9 passed, 0 filtered out.

mutation result reads as
advertises(...) replaced by a declared-triple-only comparison, dropping the recomputed-hint half 9 ran, 1 FAILED, 0 filtereda_declaration_that_disagrees_with_its_own_hint_is_unbonded step 4's hint half is load-bearing and genuinely tested; step 4 cannot be satisfied by the declared triple alone
coin_record Err(_) returns Unbonded instead of Unverified 9 ran, 1 FAILED, 0 filteredan_unreachable_chain_is_unverified_not_unbonded the outage/lie distinction is enforced, not decorative

The other two failure directions are structurally verified rather than mutated: an ABSENT pointer returns
before any chain access (bond_verify.rs:222-224), and a WRONG pointer triggers no blocklist because no
blocklist call exists on the path at all — the only action is sort_by_key (mirror_bond.rs:140).


What is right, and should not be relitigated

  • The four steps are present, in the stated order, and correct. Step 4 calls MirrorCoin::advertises
    rather than recomputing the morph; the crate doc at dig-mirror-coin-0.7.0/src/coin.rs:120-147 explains
    why hint-equality alone is forgeable, and the mutation above proves the PR relies on both halves. The
    deliberate 3/4 reordering (binding before magnitude) is well argued and correct.
  • Reordering rather than refusing is the RIGHT call for a first implementation. The reasoning — that a
    partition, an epoch rollover and a stale republished pointer are indistinguishable from a lie at read
    time, so refusing converts an outage into rejection of honest peers — is sound and I endorse it. A liar
    ranked last is still contactable, and that is acceptable: the merkle bind means a bad holder costs a
    wasted dial, not corrupt content.
  • Check 4 (the pub widening) is CLEAR. dig-node-core is unpublished (index.crates.io returns
    NoSuchKey), the returned type was already pub (download.rs:742), and exactly one out-of-crate caller
    exists (bond_verify.rs:282). The 0.65.0 to 0.66.0 minor bump is right for an additive 0.x surface.
  • Check 5 is CONFIRMED. NodeContent::for_dht — the production constructor at download.rs:1501
    calls Self::new at download.rs:1523, and new installs BondRankingLocator outermost. So the
    acceptance test at download.rs:4414 really would fail on a wiring that dropped the layer.

The problem is not the remedy. It is that the remedy's INPUT is attacker-controlled in both directions.


GATING findings

1. HIGH — the coin id is unauthenticated hearsay about a THIRD PARTY: a demotion primitive against any honest holder

crates/dig-node-core/src/mirror_bond.rs:122-124 reads record.unverified_mirror_coin_id_bytes() off
whatever record the locator was handed, with no provenance check, and ranks the peer named in
provider_peer_id by the answer. But dig-dht-0.15.0/src/service.rs:682 says of lookup-collected records:
"attributed by NOBODY: an arbitrary peer", and :778 notes ProviderRecord carries no signature.

State: honest holder H serves capsule (S,R).
Attacker action: answer one find_providers lookup — one suffices, since stop_on_providers = true ends
the walk at the first peer returning any provider (dig-dht-0.15.0/src/lookup.rs:185) — returning a slate
that names H, with H's real addresses, and attaches a BOGUS coin id.
Gain: the victim reads the chain, gets Unbonded (bond_verify.rs:95), and ranks H last on every
read. Repeat across the slate to demote every honest holder. Cost: nothing.

The existing hearsay mitigation does not cover this. dig-dht-0.15.0/src/service.rs:255 permits hearsay on
the fetch path "because the merkle bind catches it" — true of ADDRESSES, false of the coin id, which is never
fetched against but is used to judge a named third party and act on it. The same doc names the line this
crosses: "it becomes THIS NODE'S claim about the world." This PR is the first consumer to cross it.

Fix: act on the pointer only when the record is AUTHORITATIVE (mTLS-verified announcement, or
signature-checked via ingest_verified_provider). A hearsay record's pointer must be treated as absent —
already the safe default this PR implements for a missing pointer, so the fix is small.

2. HIGH — Bonded proves the coin exists, not that the claimant owns it: a free promotion

MirrorBondVerifier::verify (mirror_bond.rs:72) and verdict_for (bond_verify.rs:85) never receive the
claiming peer. advertises takes the owner from the coin's own lineage proof
(dig-mirror-coin-0.7.0/src/coin.rs:93) — correct for "does this coin bond this tuple", but the owner is
compared only against the coin's own hint, never against the peer making the claim.

Attacker action: call find_providers for (S,R) — the same public query every downloading node makes —
read an honest holder's unverified_mirror_coin_id in cleartext, and republish it as your own. dig-node
already attaches real pointers on main (seams/dig_peer/dht.rs:494, :618), so the ids are there to copy.
An independent second source needs no DHT at all: every mirror coin shares ONE puzzle hash
(dig-mirror-coin-0.7.0/src/asset.rs:29), so get_coin_records_by_puzzle_hash enumerates all of them and
the declared tuple is in the memos.
Gain: ranked Bonded, first, with zero collateral.

This bites because the verdict is now the PRIMARY sort key (mirror_bond.rs:140) and #836's deliberate
connection-verified-pool-first order survives only WITHIN a class. A stranger holding 32 copied bytes
outranks connection-verified honest peers.

Fix: bind owner_puzzle_hash() to the claiming peer's identity. mTLS alone does not do this — it proves
the record came from that peer, not that the peer owns the coin.

3. HIGH — amplification: the "cheap lookup" budget now buys up to ~170x its calibrated cost

download.rs:2603 admits one miss to find_providers per token, and its own comment at :2613 calls it
"the cheap DHT lookup the (1) budget is sized for". rate_limit.rs:165 sets burst 16.0, :169 refill
4.0/sec, and the doc at :161 sizes it precisely BECAUSE a miss lookup is cheap — the genuinely expensive
legs (:179 proxy, :200 relay ask) each get a quarter of it.

This PR makes that same admitted lookup perform up to two blocking chain RPCs per located record
(bond_verify.rs:92, :116) from a sequential loop (mirror_bond.rs:122-124), adding no bound of its own
and not resizing the limiter. A single malicious responder supplies the whole slate: up to 64 records
(dig-dht-0.15.0/src/lookup.rs:45) plus up to 20 local (src/provider_store.rs:54). One admitted miss
becomes ~84 chain reads; sustained at 4 tokens/sec that is ~340 chain RPCs/sec from one remote source
against this node's chain source.

Memoisation does not save it, and the attacker controls that too: MAX_CACHED_VERDICTS = 1024
(bond_verify.rs:60), overflow handled by cache.clear() (:178-179) — a full wipe, not an eviction —
keyed on a stranger-supplied coin_id (:69). Rotating more than 1024 ids thrashes the cache empty, so
every locate re-reads AND every honest Bonded verdict is discarded with it.

Latency compounds it: the loop is sequential and each read is block_in_place (:254), so one locate can
hold a tokio worker for N x RTT — roughly 13 s at 84 records and 150 ms RTT — on a path a stranger reaches.

Fix: bound the verified records per locate, verify concurrently rather than sequentially, and make the
cache evict rather than clear. Finding 1's fix helps here too: restricting the pointer to authoritative
records collapses the attacker-chosen fan-out.


NON-GATING (file as follow-ups; do not hold the PR on these)

  • MEDIUM — a per-record disk read+parse the cache cannot absorb. bond_verify.rs:228 calls
    epoch_and_requirement() BEFORE the cache probe at :238; that helper (:201) reaches
    EpochRecordStore::get (collateral.rs:647-667), a full read_to_string plus line-by-line serde_json
    parse — on every record, on every locate, even on a cache hit — as plain blocking I/O on the async worker
    with no spawn_blocking. Moving the cache probe ahead of it costs nothing.
  • MEDIUM — the install path is untested and fails silently. spawn_bond_verifier_install
    (bond_verify.rs:276) is gated on peer_network_enabled() and config.enable_chain_sync
    (server.rs:2165), polls p2p_content() 60 times at 2 s (:281, :291), and gives up after 120 s with
    only a tracing::debug! (:293). A node whose engine came up slowly never verifies and looks identical
    to one that does — the same failure class the acceptance test was written to end, one level up.
  • MEDIUM — ChainBondVerifier has ZERO tests. The TTL, the 1024 bound, the clear-on-overflow and
    epoch_and_requirement are exercised by nothing in the repo; every test drives the pure verdict_for.
  • LOW — a stale positive can outlive a spend by up to 600 s. VERDICT_TTL (:52) caches a definite
    Bonded, so a coin reclaimed inside the window still ranks first for the remainder. Bounded and
    ranking-only; acceptable, but worth stating in SPEC 25.6a.

On the limitation the lane declared: it does NOT gate on its own, but it is deeper than stated

The lane was right that dig_mirror_coin::discover needs owner_puzzle_hash, that ProviderRecord carries
no owner, that no peer_id -> owner mapping exists in the repo, and it was right to deliver the effect and
say so rather than fake the fallback. As a FALLBACK gap that is acceptable for this PR, and the consequence
the brief names — a pointerless publisher can only ever be Unverified — is real but bounded: Unverified
still ranks above Unbonded, so absence does not read as uncollateralised, which is what the contract
forbids.

But that missing mapping is not merely why the hint scan cannot be initiated. It is the reason the
verification cannot be bound to the claimant at all
, which is the root of findings 1 and 2 both. Track it
as the blocker for those, not as a fallback nicety.

What I could not reach

  • The dig-node-core unit tests in mirror_bond.rs and the acceptance test at download.rs:4414 were READ,
    not executed. The mutation evidence covers verdict_for only, via the service integration suite;
    BondRankingLocator's ranking itself is unmutated.
  • ChainBondVerifier was not exercised end to end — no test exists and no chain source was available.
  • Behaviour AFTER the owed rebase is unverified: the PR is CONFLICTING against main 6e2475a. Re-read
    the version from Cargo.toml on disk afterwards, never from the log — a rebase silently DROPS a bump
    commit whose patch is already upstream, with RC=0 and no conflict.
  • gitnexus impact was not used (index ~301 commits stale); blast radius came from grep plus direct read.

Verdict: CHANGES-REQUIRED. Findings 1, 2 and 3 are live and remotely reachable. Finding 1 in particular
lets a stranger aim a demotion at an honest peer, which is strictly worse than the pre-PR state where the
field was inert. The shape of this PR is good, the four steps are right, and the reordering choice is the
correct one; what is missing is authenticating and binding the input the ranking acts on.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — audit of the credit-only decision (NOT an implementation)

Head read: 3b978d9618119cf418df4c70b5d6c5d23830ee06, still DRAFT. main confirmed at 3ce4785.

I did not write code and will not. This gate's value is that it is independent of the maker
(CLAUDE.md 1.9); if I implement the fix I proposed, the one context that has threat-modelled this
deeply becomes ineligible to gate it. This needs a loop-implementer lane. Everything below is the
audit output that lane needs, including two things that change the plan.


1. Your open question is RESOLVED: the dial does NOT pin peer_id

This is no longer a residual footnote — under credit-only it becomes load-bearing, because promotion is
now the only thing the layer does.

  • dig-gossip rev 1a33916 (the rev this PR locks) has no expected-peer-id parameter anywhere in
    production
    . grep -rn "expected_peer\|expect_peer\|want_peer" src/ returns hits in exactly one
    file, src/connection/rustls_inbound.rs:273,293,332 — and #[cfg(test)] mod tests begins at
    line 192, so all three are inside the test module. The only production statement is derivation:
    outbound.rs:14,32,119, "PeerId = SHA256(remote SPKI DER)". Deriving an identity is not pinning
    one.
  • The download path does not pin either. In dig-download-0.22.0, provider_peer_id is used as a
    health/selection key and passed through (src/source.rs:179, src/module.rs:648,720,1019); its own
    src/error.rs:368 calls it "free-form text off the wire". No comparison against the dialled endpoint.

So peer.rs:4758's comment — "every dial to this node fails closed with peer_id mismatch" — is a
doc-comment claim not backed by production source. It should be corrected or removed in this PR;
a false safety claim in a comment is how the next reviewer skips the check.


2. Part 1 of the decision is technically SOUND — I verified the claim it rests on

dig-mirror-coin-0.7.0/src/create.rs:99-108 writes the memo layout [hint, store, root, epoch, url...],
and src/coin.rs:490-499 collects every trailing entry that is valid UTF-8 into urls, dropping the
rest. The tail is genuinely arbitrary UTF-8, and it is written by the parent spend, which only the owner
key can produce. A dig-peer:<64-hex> term in that tail is a statement by the coin's owner, verified by
executed on-chain code.
Correct, and it needs no dig-dht wire change.

3. Part 2 kills Finding 1 completely — I endorse it

Collapsing absent / Unverified / Unbonded into one baseline tier removes the demotion primitive by
construction
, not by degree. Hearsay can withhold credit and can never subtract it. That was my worst
finding and this closes it cleanly. Ship the lattice.

4. But it does NOT kill Finding 2 — and neither prescribed test would catch what is left

The memo term binds coin -> peer_id. It does not bind peer_id -> record, and a lookup-collected
record has all three of its fields attacker-chosen, not just the coin id.

Attack on the credit-only design. Attacker answers one lookup (stop_on_providers = true,
dig-dht-0.15.0/src/lookup.rs:185, so one answer takes the whole slate) with a record carrying:

field value
provider_peer_id H (honest, bonded)
unverified_mirror_coin_id H's real coin — which declares H
addresses the attacker's addresses

Every check passes: the coin bonds the content, the coin declares peer H, the record claims peer H.
The record is promoted to first — and it points at the attacker. Because the dial does not pin
(section 1), the attacker then serves the traffic while holding H's rank. The method changed from
"copy the coin id onto my own peer id" to "copy the coin id and the peer id onto my own addresses";
the outcome — an unbonded stranger at top rank — did not.

Why your two tests miss it. Test 1 gives the liar its own peer_id, which is exactly the case
the memo binding fixes. Test 2 is about demotion. Neither varies the ADDRESSES while holding
peer_id and coin id honest
, so both pass against a build with this hole wide open. A third test is
required:

a record naming an honest holder's peer_id and its real coin id, but carrying different
addresses
, must not be promoted — assert on the addresses that come back, not on the peer id,
because the peer id is identical in the passing and failing versions.


5. The real remedy costs a dig-dht release — and this is why it does NOT block this PR

The fix is to promote only on an authoritative record (an mTLS-verified announcement, or one
signature-checked through ingest_verified_provider, where dig-gossip's
holdings_announce.rs:568 already enforces SHA-256(provider_spki) == provider_peer_id). The binding
this needs therefore already exists — but dig-node cannot currently see it:

  • ProviderRecord has exactly five fields — content_key, provider_peer_id, addresses,
    expires_at, unverified_mirror_coin_id — and no provenance marker.
  • There is no authoritative-only per-content accessor. find_providers merges local and discovered
    into one Vec (dig-dht-0.15.0/src/service.rs:243, merge_dedup_by_provider at :891);
    cached_providers returns hearsay only.

So the remedy is a release-first dig-dht 0.16 (expose provenance, or an authoritative-only read), the
same cascade shape the decision already accepts for dig-mirror-coin 0.8.0.

Scheduling — this is the part that makes the decision still shippable. The residual becomes live at
exactly the same moment promotion does. While no coin carries a dig-peer: term, nothing is ever
promoted, and an unpromotable layer cannot be captured. So: ship credit-only now; the
authoritative-record restriction must land before or with dig-mirror-coin 0.8.0 adoption, never
after.
Track it as a blocker on the 0.8.0 adoption ticket, not as a follow-up.

6. A fork the implementer will hit in the first hour — decide it now

"Bonded is unreachable until 0.8.0" is true because no coin contains a dig-peer: term, not
because 0.7.0 cannot read one: MirrorCoin::urls() already returns that tail today. So the implementer
has two options and one of them is wrong:

  • Parse dig-peer: out of urls() against 0.7.0 — makes promotion reachable immediately, and
    creates a second parser for a format dig-mirror-coin 0.8.0 is about to own. That is the
    rival-implementation rule (CLAUDE.md 2.0) violated on day one, on a security-critical parse.
  • Gate promotion off until 0.8.0's typed accessor exists. RECOMMENDED. The promotion tier is
    present, tested, and unreachable because its peer-binding check has no sound source yet. That is
    "inert-but-recorded" reached honestly, it avoids the rival parse, and it makes section 5's residual
    provably unreachable in the interim rather than merely unlikely.

Whichever is chosen, say which in the PR body, because the two produce identical-looking green
suites and opposite security postures.


7. Finding 3 (amplification): bound it in THIS PR, and the interim makes the case stronger, not weaker

Credit-only does not touch it — the chain reads happen per record regardless of what the lattice does
with the answer. But it changes the cost/benefit sharply, in the direction of acting now:

While promotion is unreachable, every one of those up-to-84 blocking chain reads per locate is pure
cost with literally zero effect on any outcome
— and a stranger picks the volume. Shipping a
remotely-triggered fan-out that cannot change a decision is the weakest possible position to defend.

Cheapest bounds, in order of value per line changed:

  1. Cap the records verified per locate to a small constant in source order. Kills the
    attacker-chosen fan-out outright (mirror_bond.rs:122-124).
  2. Move the cache probe ahead of epoch_and_requirement() (bond_verify.rs:228 before :238).
    Removes a full read_to_string + line-by-line serde_json parse of the epoch file
    (collateral.rs:647-667) per record per locate, currently unabsorbable by the cache and executed as
    blocking I/O on the async worker.
  3. Evict instead of cache.clear() (bond_verify.rs:178-179), so a stranger rotating >1024 ids
    cannot discard honest verdicts.
  4. Verify concurrently rather than sequentially, removing the N x RTT worker hold (:254).

(1)+(2)+(3) are small and independent of both the lattice and 0.8.0. If only one lands, make it (1).


What I am handing back

  • The decision is right in its core — the memo binding is sound and the credit-only lattice kills
    the demotion primitive by construction. I endorse shipping it.
  • Two corrections: the claim that it "kills both HIGH findings by construction" does not hold for
    Finding 2
    , and the two prescribed tests would not detect the remainder. A third test and the
    section 5 restriction are needed, on the 0.8.0 schedule rather than this one.
  • Your unverified item is now verified: the dial does not pin peer_id, so credit-stealing would
    indeed be traffic redirection rather than a wasted dial once promotion is live. Section 5 is what
    keeps that unreachable.
  • No code was written by me, no failing-then-passing runs to report, and the version/lockfile work
    is untouched — that is an implementer lane's job, and it should re-gate against a fresh context
    rather than this one.

Keeps this branch's 0.200.0 (main is at 0.198.0) and takes main's Cargo.lock as the
base; the lockfile is refreshed in the follow-up commit. `mirror/mod.rs` keeps both
sides: this branch's `bond_verify` module and main's `converge_tests`.
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-implementer — progress (still DRAFT, do not merge)

Branch loop/mc-verify, head e16a6c4 (merge of origin/main @ 3ce4785; version kept at 0.200.0, Cargo.lock taken from main and being refreshed).

Implemented, uncommitted-beyond-the-merge at time of writing:

  • verdict_for and MirrorBondVerifier::verify now take the claiming peer id.
  • mirror_bond.rs is credit-only: Bonded promotes, Unverified/Unbonded/absent are one baseline tier, stable within tier.
  • Amplification bounded: MAX_VERIFIED_PER_LOCATE = 8, cache probed before the epoch file read, and the verdict cache evicts one entry instead of clear().
  • Promotion gated off at peer_declaration() until dig-mirror-coin 0.8.0 exposes a typed dig-peer: accessor — deliberately no rival parser over MirrorCoin::urls().
  • peer.rs fail-closed peer_id mismatch claims corrected (dig-gossip#85: production only derives).

Environment note: rustc on this host is crashing with STATUS_STACK_BUFFER_OVERRUN on the dig-node-core lib-test target (and, at higher -j, on unrelated dependency crates). Not a code error — no error[E…] anywhere in the output. Retrying with CARGO_INCREMENTAL=0, -j 2, larger RUST_MIN_STACK.

Next action: finish the test runs (red-then-green with counts), refresh Cargo.lock, push.

…ming peer (#466)

`verdict_for` and `MirrorBondVerifier::verify` now take the claiming peer id. Without it
the layer could only ask "does some coin bond this content", which a stranger passes
truthfully by republishing an honest holder's coin id under its own record.

The ranking becomes credit-only: `Bonded` promotes, and absent / `Unverified` /
`Unbonded` are one baseline tier that preserves source order. A disproven pointer can no
longer rank a holder below where no pointer would have -- otherwise attaching a bogus
coin id to an honest holder's record is a demotion primitive any stranger gets for free.

Promotion is gated off at `peer_declaration()` until dig-mirror-coin 0.8.0 exposes a
typed `dig-peer:` accessor. `MirrorCoin::urls()` already returns that tail, so it could
be parsed here -- and must not be: a second parser for a security-critical format, in the
consumer, diverges silently rather than failing to compile.

Amplification bounded: at most MAX_VERIFIED_PER_LOCATE (8) chain reads per locate, the
verdict cache is probed before the epoch file is read, and cache overflow evicts one
entry instead of clearing (a stranger rotating coin ids could otherwise discard every
honest verdict).

Also corrects peer.rs's claim that a dial fails closed on `peer_id mismatch`: every
`expected_peer_id` in dig-gossip is test-only and production merely derives
(DIG-Network/dig-gossip#85), so a split identity is not caught by the handshake.
…ce control survives

`chain_bond_verdict` answers "does this coin bond this content"; `verdict_for` adds
"and does it name the peer claiming it". The split keeps `tests/mirror_bond_verify.rs`'s
`Bonded` control meaningful -- routed through `verdict_for` the honest coin and a coin
nobody could look up would both answer `Unverified`, so every negative case would be
equally explained by a fixture too broken to verify at all.

Adds a test on the SAME on-chain fixture asserting both halves: the chain establishes
the bond, and the claim is still not promoted to a peer the coin does not name.
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Update — head e59548a. Two things changed after the PR body above was written; both are corrections, not additions.

1. verdict_for is split into chain_bond_verdict + verdict_for. cargo clippy --all-targets (which --lib runs do not reach) found tests/mirror_bond_verify.rs calling the old 6-argument signature. Those ten conformance tests assert Bonded on real CAT-spend fixtures, and routing them through the gated verdict_for would have made the honest coin and a coin nobody could look up both answer Unverified — destroying the control that gives every negative case in that file its meaning. So the chain half keeps its own entry point and its own signature, and verdict_for is the chain half plus the peer gate.

2. A new test on the same on-chain fixture asserts both halves at once: chain_bond_verdict says Bonded (the control), and verdict_for with a claimant the coin does not name says Unverified. That is a stronger statement of the addresses finding than the unit test, because the coin is genuine rather than modelled.

Runs after the split, all unpiped, CARGO_INCREMENTAL=0 -j 2:

  • -p dig-node-service --test mirror_bond_verifyok. 10 passed; 0 failed (including a_coin_that_passes_every_chain_check_is_still_not_promoted_to_a_claimant)
  • cargo clippy -p dig-node-core -p dig-node-service --all-targetsno errors, no warnings
  • cargo metadata --locked --offline → clean, so CI's --locked has a consistent lockfile

Still DRAFT. The gate that produced the audit should re-audit this in a fresh context; it declined to implement, correctly.

# Conflicts:
#	Cargo.lock
#	Cargo.toml
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-implementer (resumed lane) — progress. Still DRAFT, do not merge.

The prior lane died at the session cap with everything pushed and nothing uncommitted — its worktree was clean at e59548a and matched remote, so there was nothing to salvage and no work was lost.

What this session changed

origin/main advanced to 0.206.0 (a5507db, #475) while this branch sat at 0.200.0, so the version gate would have failed on a stale base.

  • Merged origin/main into loop/mc-verify. Conflicts in Cargo.toml and Cargo.lock only; every source conflict auto-merged.
  • Version re-read from Cargo.toml on disk after the merge and re-bumped to 0.207.0 (above main's 0.206.0, not the branch's stale 0.200.0). crates/dig-node-core stays at 0.66.0 over main's 0.65.0.
  • Cargo.lock taken from main and re-resolved: cargo update -w --offline moved exactly the two workspace members and locked nothing else.

Head is now d3a6ae1.

Verification on the merged tree

check result
cargo fmt --all -- --check clean, exit 0
cargo clippy -p dig-node-core -p dig-node-service --all-targets exit 0, zero errors, zero warnings
cargo test -p dig-node-core -p dig-node-service --lib running

Review of the inherited implementation against the two gates

Read against dig-node#473's DECIDED comment and this PR's loop-security audit; no correction was needed.

  • HIGH 1 — "Bonded proves the coin exists, not that the claimant owns it." Closed at verdict_for (bond_verify.rs:208): the chain half must pass and declared_peer must answer DeclaresThisPeer. Anything else degrades to Unverified. peer_declaration returns NotReadable unconditionally today, so promotion is off by construction rather than by a flag, exactly as the decision specifies for the pre-0.8.0 interim.
  • HIGH 2 — "the demotion path makes the verifier net-negative." Closed by credit_rank (mirror_bond.rs:130): Bonded is tier 0, Unverified and Unbonded are both tier 1, and the sort is sort_by_key on a Vecstable, so source order survives within the tier. BondVerdict carries no Ord derive, so a future variant cannot acquire a ranking from its declaration position.
  • The failure direction this lane was told to guard — a chain read that fails must not demote an honest holder — holds structurally, not by care: Err(_) maps to Unverified at every source call, and Unverified and Unbonded share a tier, so an unreachable chain cannot move any record at all.

Next action

Report the lib-suite counts, update the PR body's version and head, and hand back to the orchestrator for a fresh gate round. This lane does not undraft and does not merge.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-reviewer — IN PROGRESS, not the verdict

Head read: d3a6ae1477e2aed1fbc632ca26d68b6296d1963c.

Established so far:

  1. Version on disk is correct. Cargo.toml:35 = 0.207.0 (read from the file at the merge head, not the log), above main's 0.206.0. The merge did not drop the bump.
  2. HIGH 2 (demotion) is genuinely closed. mirror_bond.rs:130 credit_rank gives Bonded tier 0 and Unverified | Unbonded a shared tier 1; sort_by_key is stable; BondVerdict derives only Debug, Clone, Copy, PartialEq, Eq — no Ord, so the tier function is the only ordering in play. Credit-only holds.
  3. HIGH 1 (existence vs ownership) is closed at verdict_for (bond_verify.rs:225-231): a non-Bonded chain half short-circuits, and the chain half is downgraded to Unverified unless declared_peer returns DeclaresThisPeer. peer_declaration (:111-116) returns NotReadable unconditionally, so promotion is off by construction today.

One gating finding found, detail to follow in the verdict: VerdictKey (bond_verify.rs:68-74) is keyed on (coin_id, store, root, epoch) and omits the claiming peer id, while verdict_for's answer depends on it. Inert today only because Bonded is unreachable; it becomes a cross-peer promotion cache the moment the 0.8.0 accessor lands — and peer_declaration's own doc at :109-110 tells the next maintainer that swapping that body is "the whole of the change".

Still to do: every Err(_) call site, a revert probe, and the readable-code pass.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Auditing head d3a6ae1477e2aed1fbc632ca26d68b6296d1963c (resolved from the remote), merge-base
a5507dbf. Own detached worktree, shared checkouts untouched. Posting as I establish each item so
nothing is lost to a kill.

Confirmed CLEAN so far

1. Promotion is off by construction, and the whole lattice is inert today.
peer_declaration() (crates/dig-node-service/src/mirror/bond_verify.rs:111-116) returns
NotReadable unconditionally. verdict_for (:228-231) maps both Silent and NotReadable to
Unverified, so the production ChainBondVerifier cannot return Bonded on any input. No path
treats NotReadable as DeclaresThisPeer — the only two matches on PeerDeclaration are
bond_verify.rs:228-231 and the test double at :551-554, and both fold NotReadable into
Unverified. Tier 0 is unreachable.

2. Demotion is impossible — verified against the artifact, not the comment.
credit_rank (crates/dig-node-core/src/mirror_bond.rs:130-135) maps Unverified and Unbonded
to the SAME tier 1. Combined with (1), every record in production gets rank 1, and
ranked.sort_by_key (:204) is sort_by_key, which is documented-stable — so the located order is
returned unchanged. An attacker cannot demote an honest holder by attaching a bogus coin id to its
record, and cannot demote one by inducing chain-read failures either: every Err(_) arm maps to
Unverified (bond_verify.rs:147, :170, :177), which shares a tier with Unbonded anyway.

3. The guard test is real and will fail when 0.8.0 lands.
no_visible_term_promotes_a_claim_before_the_typed_accessor_exists (bond_verify.rs:594-609) calls
the REAL peer_declaration over four fixtures including a well-formed dig-peer:<64-hex> term that
matches the claimant exactly (:600). A typed accessor that starts returning DeclaresThisPeer for
that input fails the assert_ne! at :603. It is not a name-only guard.

4. The locate budget is enforced on the artifact.
MAX_VERIFIED_PER_LOCATE = 8 (mirror_bond.rs:127) is checked in the real production loop at
mirror_bond.rs:181 (claimed.is_none() || verified == MAX_VERIFIED_PER_LOCATE), and verified
increments only on the branch that calls out (:185). The test at :438-455 drives the REAL
BondRankingLocator with a 40-record slate and asserts the counter on the verifier double equals
MAX_VERIFIED_PER_LOCATE — a bound on the artifact, not on an argument. It also asserts
got.len() == 40, so budget exhaustion withholds credit rather than dropping holders.

5. No secrets, no custody, no unsafe API widening.
The only new logging is mirror_bond.rs:193-196 (a peer id, already public wire data) and two
tracing::info!/debug! install lines carrying no data. No key material, no signing, no spend
construction anywhere in the delta. The one visibility widening is
Node::p2p_content pub(crate) -> pub (download.rs:2547); it returns Option<&Arc<NodeContent>>
whose mutating surface here is set_bond_verifier, which is OnceLock::set — idempotent, one-way,
first-call-wins (download.rs:1554-1559). A second caller cannot swap a running node's verifier.

Still open

Chain-read cost asymmetry per locate, and the cached_epoch() hint's staleness claim. Reporting
next.

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loop-reviewer inline finding — see thread.

Comment thread crates/dig-node-service/src/mirror/bond_verify.rs
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-reviewer — VERDICT: CHANGES-REQUIRED

Head reviewed: d3a6ae1477e2aed1fbc632ca26d68b6296d1963c (resolved from the remote at review start; unchanged throughout).

One gating finding. It is cheap to fix and everything else on this head is genuinely good — the design decided on #473 is implemented faithfully, both earlier HIGH findings are closed by construction rather than by comment, and the revert probe I executed shows the tests are not vacuous.

GATING

1. crates/dig-node-service/src/mirror/bond_verify.rs:68-74 — the verdict cache is keyed without the claiming peer id, while the verdict depends on it. Full detail and the exact fix are in the inline thread on that line. Summary: verdict_for answers a peer-dependent question, VerdictKey is (coin_id, store, root, epoch), so a Bonded earned by the peer a coin declares will be served from cache to any other peer republishing that coin id within the 600s TTL — HIGH finding 1 reintroduced through the memo layer. Inert on this head only because peer_declaration:111-116 makes Bonded unreachable, and two artefacts direct the next maintainer straight into it: the doc at :109-110 says swapping that body is "the whole of the change", and SPEC.md §25.6a codifies the peer-agnostic cache key as normative.

Verified — the claims in the brief, checked against code rather than comment

Version. Cargo.toml:35 reads 0.207.0 on disk at the merge head, above main's 0.206.0. The bump survived the merge. The branch's fixes are all present in the merged tree (mirror_bond.rs, bond_verify.rs, the download.rs wiring, the peer.rs doc correction, the SPEC section) — no conflict resolution dropped anything.

HIGH 2 (net-negative demotion) — genuinely closed. mirror_bond.rs:128-134: Bonded tier 0, Unverified and Unbonded both tier 1. sort_by_key at :201 is stable. BondVerdict derives Debug, Clone, Copy, PartialEq, Eq only — no Ord, so credit_rank is the sole ordering and a future .sort() on the verdict cannot compile. Credit-only holds structurally.

HIGH 1 (existence vs ownership) — genuinely closed. verdict_for:225-231 short-circuits a non-Bonded chain half and downgrades to Unverified unless declared_peer returns DeclaresThisPeer. peer_declaration:111-116 returns NotReadable unconditionally, so promotion is off by construction, not by flag. declared_peer:239-254 returns NotReadable on every read failure — never Unbonded — so the ownership leg cannot demote either. Refusing to parse the memo tail here rather than duplicating the format ahead of dig-mirror-coin 0.8.0 is the right call under the centralize-rivals rule.

Failure direction — every source call site checked, all correct. :147 Err(_)Unverified; :170 Ok(None) | Err(_) on the creating spend → Unverified; :177 ChainUnavailableUnverified; :315-321 a chain source that cannot be built → Unverified; :406-435 an absent pointer, a store-granularity id, and an unsettled epoch → Unverified; :276 and :349 a poisoned cache mutex degrade to a miss and a skipped write rather than a verdict. Nothing anywhere maps a local failure to Unbonded. :192 an uncensused epoch → Unverified after the binding check, which is the right order and is argued correctly in the module doc.

Test vacuity — one probe executed, not read. I reverted only credit_rank (Unverified => 1, Unbonded => 2) in my own detached worktree and ran cargo test -p dig-node-core --lib a_bogus_pointer_leaves_an_honest_holder_exactly_where_no_pointer_would:

running 1 test
test mirror_bond::tests::a_bogus_pointer_... FAILED
  left: ["bb", "aa", "cc"]
 right: ["aa", "bb", "cc"]
test result: FAILED. 0 passed; 1 failed; 1038 filtered out

One test ran — not a filter matching nothing — and it failed on exactly the property it names. The test's construction is also right: the control is the same slate with the pointers removed rather than a reversal, so a fix that merely sinks the holder less would not pass. Worktree restored, git status --porcelain empty.

The rest of the suite reads as non-vacuous by construction. The integration tests build real CAT spends rather than mocking advertises; a_declaration_that_disagrees_with_its_own_hint_is_unbonded covers the freely-chosen-epoch morph collision the crate warns about; the_collateral_requirement_is_bounded_from_both_sides proves both inequalities rather than one; a_locate_reads_at_most_the_budget_off_the_chain asserts the chain-read COUNT rather than elapsed time; and an_unreachable_chain_is_unverified_not_unbonded pairs the partition with a live-chain control varying exactly one thing, so the partitioned half cannot be satisfied by a layer that does nothing.

Acceptance criteria (#466) — met. The verifier is on a production path, not a crate-internal helper: BondRankingLocator wraps the locator inside NodeContent::new (download.rs:1253-1259), so the raw discovery leg and the download union inherit one ranking, and the_engine_ranks_a_disproven_bond_last_on_its_own_discovery_path exercises the real constructor. All four controls the ticket names are present. The enable_chain_sync gate on install (server.rs:2176) is the right condition.

Non-gating — resolved by me, not blocking

  • bond_verify.rs:180 maps MirrorError::Malformed, Unauthenticated and Driver to Unbonded under one Err(_). Driver in particular is a local computation failure rather than a false claim, so strictly it belongs with Unverified under the three-state discipline the module argues for. Costs nothing today because both tiers rank identically, and the publisher-chose-this-coin justification in the comment is reasonable. Worth a line if the lattice ever gains a third tier.
  • SPEC.md §25.6a is unusually good — it states the credit-only property, the hearsay reasoning and the step-3/step-4 ordering normatively rather than describing the implementation. The one clause to amend is the cache-key sentence, in the gating finding above.

Readable-code (§2.5)

Passes, and comfortably. Names carry intent (credit_rank, bondable_tuple, PeerDeclaration::Silent vs NotReadable), the comments are WHY rather than what, every public item is documented, and each test's doc says what it proves AND what it catches. Both module headers explain the security reasoning behind a choice rather than restating the code — mirror_bond's explanation of why two tiers rather than three, and bond_verify's of why steps 3 and 4 are ordered as they are, are the kind of thing a reader would otherwise have to reconstruct. The seam that keeps the chia dependency set out of dig-node-core is the right boundary.

To clear this gate

Fix the cache key, correct the two sentences that codify it, and re-request. The re-gate is scoped to that change only — nothing else on this head needs re-reading.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — VERDICT: CHANGES-REQUIRED

Head audited: d3a6ae1477e2aed1fbc632ca26d68b6296d1963c (resolved from the remote via
gh pr view 467 --json headRefOid; matched the dispatch brief). Merge-base a5507dbf. Read from my
own detached worktree; no shared checkout or sibling worktree was touched.

The two HIGH findings from round 1 are genuinely fixed — see the CLEAR list. One new finding
gates.


GATING

F1 — MEDIUM/HIGH: a remote caller converts one cheap-lookup token into ~32 uncacheable third-party HTTPS reads, for a verdict that is provably discarded

Where: crates/dig-node-service/src/mirror/bond_verify.rs:208-232, :239-254, :343-345;
crates/dig-node-core/src/mirror_bond.rs:127, :181-198.

The read is outbound egress at a third party. The production ChainSource is
ChiaQueryProvider, and crates/dig-wallet/src/sage/chain.rs:317-321 states it plainly: with
coinset_fallback_enabled — "the default every production fabric is built from" — it asks
api.coinset.org FIRST. So every coin_record / coin_spend in this delta is an outbound HTTPS
request to a shared third party.

Four reads per bonded holder, not two. verdict_for calls chain_bond_verdict, which reads
coin_record (:142) and coin_spend (:166). When that returns Bonded it then calls
declared_peer (:228), which repeats the identical two reads (:244, :247). That is 4 reads
for one holder.

Never cached. declared_peer returns NotReadable, so verdict_for returns Unverified
(:230), and remember explicitly refuses to cache Unverified (:343-345). The 4 reads are
re-paid on every locate, permanently.

Multiplied by the locate budget. MAX_VERIFIED_PER_LOCATE = 8 gives up to 32 coinset reads per
locate
.

Admitted by one token of a bucket sized for something else. One locate costs a single token of
miss_rate_limiter (crates/dig-node-core/src/download.rs:2603), burst 16 / refill 4-per-sec
(crates/dig-node-core/src/rate_limit.rs:165, :169), whose own doc sizes it against "a cheap DHT
lookup". Sustained that is ~128 coinset HTTPS requests/sec per requestor identity, and a
RequestorId::Peer is a self-minted SHA-256(SPKI), so the keying is Sybil-cheap.

Concrete exploit. State: any capsule with bonded holders; their coin ids are published in DHT
provider records by design, so they are public. Attacker action: issue dig.getContent for
(store, root, resource_N) with N incrementing, against a node that does not hold it.
Each request misses the holder cache — FirstHandHolderCache is a TtlMap<ContentId, ...>
(crates/dig-node-core/src/seams/dig_peer/holder_cache.rs:162) keyed on the full ContentId
including the resource — so each one re-walks. CapsuleFallbackLocator (#1580) then returns the same
capsule-granularity bonded holders every time, and both bondable_tuple (bond_verify.rs:369-376)
and VerdictKey (:68-74) drop the resource, so it is the same coins re-verified on each new key.
Impact: sustained attacker-directed egress at api.coinset.org, plus 8x4 serialized blocking round
trips inside one find_providers on the redirect handler.

And it buys nothing. peer_declaration (:111-116) has exactly one unconditional
return PeerDeclaration::NotReadable; DeclaresThisPeer is constructed nowhere in the tree; so
verdict_for cannot return Bonded. credit_rank (mirror_bond.rs:130-135) gives Unverified and
Unbonded the same tier 1. Every record therefore ranks 1, and sort_by_key (:204) is stable
— so the returned slate is provably identical to the input slate. The layer performs the network
I/O and discards the answer.

Why this is not merely a performance note. Getting throttled or banned by coinset degrades the
same ChainTransport that dig-wallet reads through, so a stranger's cheap content requests can
degrade this node's money path. This repo has already twice established the governing precedent —
DEFAULT_PROXY_FETCH_BURST (#2189) and DEFAULT_RELAY_ASK_BURST (#3128) each gave an expensive leg
its own tighter bucket, and the latter's doc names this exact hazard: "let a caller convert
lookup tokens into fan-out at third parties". This leg draws on the cheap-lookup allowance at up to
32x and has no bucket of its own.

Minimal remediation (behaviour-preserving, provably). While peer_declaration is a constant
NotReadable, the verifier's output cannot affect the ranking. Either return Unverified from
ChainBondVerifier::verify before any chain read, or do not install the verifier at all, until
0.8.0's typed accessor lands. Both are no-ops on output by the argument above. If you prefer to keep
the reads live, then the duplicate pair in declared_peer should go (return the MirrorCoin from
the chain half) and this leg needs its own bucket in the shape of #2189/#3128.


NON-GATING (recommend follow-up tickets; do not hold the merge on these)

F2 — LOW: two born-false claims in declared_peer's doc comment

bond_verify.rs:236-238 says "in production this path is unreachable until the declaration has a
typed source" and "the read is memoised one layer up". Both are false. The path is reached whenever
the chain half returns Bonded — and this PR's own test proves it:
crates/dig-node-service/tests/mirror_bond_verify.rs:384-412 asserts chain_bond_verdict(...) is
Bonded and verdict_for(...) is Unverified on one fixture, which is only reachable by executing
declared_peer. The memoisation claim is false because the resulting Unverified is the one verdict
remember refuses to cache (:343-345). These two sentences are exactly what would lead a reader to
skip F1's cost analysis.

F3 — LOW: cached_epoch()'s stated invariant does not hold across a rollover

bond_verify.rs:286-287 claims a stale value "can only produce a cache miss, never a verdict taken
under the wrong epoch, because the epoch remains part of the key." But the key is built from the
stale epoch (:425-430), so it hits the entry stored under the OLD epoch and returns a verdict taken
under it. Bounded by VERDICT_TTL (600s) and harmless today (Bonded unreachable;
Unbonded/Unverified share a tier). When the 0.8.0 accessor lands this becomes a 600s window in
which a bond that expired at an epoch rollover still earns tier-0 promotion. Correct the comment now
and re-check at 0.8.0.

F4 — INFO, unproven

mirror_bond.rs:193-196 renders record.provider_peer_id with % into a tracing::debug!.
dig-dht documents that field as 64-hex (record.rs:271) but it is a wire String; I did not
establish whether ingest validates it. If it does not, this is a debug-level log-injection surface.
Worth one grep in dig-dht, not worth a gate.


CLEAR — checked, and why each is clear

  1. Promotion with a bond you do not own — CLOSED. peer_declaration (bond_verify.rs:111-116)
    returns NotReadable unconditionally; DeclaresThisPeer is constructed nowhere; both match
    sites (:228-231, and the test double :551-554) fold it to Unverified. Tier 0 is unreachable
    by construction. The liar-republishing test (mirror_bond.rs:365-389) drives the real
    BondRankingLocator and additionally asserts the claiming peer id reached the verifier
    (:385-388), so an implementation that dropped the parameter fails it — it is not a double
    encoding the defect. The residual-hole test (bond_verify.rs:573-592) asserts on addresses
    rather than order, through the real peer_declaration.
  2. Demotion of an honest holder — CLOSED, both routes. Direct: credit_rank
    (mirror_bond.rs:130-135) puts Unverified and Unbonded in the same tier, so a bogus pointer
    cannot sink anyone; the test at :399-428 compares against a real control slate with the pointers
    removed, not against a reversal. Via induced chain failures: every Err(_) maps to Unverified
    (bond_verify.rs:147, :170, :177), which shares that tier anyway.
  3. The locate budget is on the artifact. mirror_bond.rs:181 is the real production check and
    verified increments only on the calling branch (:185); the test (:438-455) drives the real
    locator with a 40-record slate and asserts the double's call counter equals
    MAX_VERIFIED_PER_LOCATE, plus got.len() is 40 so exhaustion withholds credit rather than
    dropping holders. What the budget bounds is verifier calls, not chain reads — that gap is F1.
  4. The 0.8.0 guard test is real.
    no_visible_term_promotes_a_claim_before_the_typed_accessor_exists (bond_verify.rs:594-609)
    calls the real peer_declaration over four fixtures including a well-formed dig-peer:<64-hex>
    matching the claimant exactly (:600); a typed accessor returning DeclaresThisPeer fails the
    assert_ne! at :603. Not a name-only guard.
  5. Fail-closed ordering. The limiter runs before the expensive step (download.rs:2603
    precedes locate_holders at :2639) — correct ordering; F1 is about the token's price, not
    its absence. A failed locate stays a failure and is never rewritten to an empty slate
    (mirror_bond.rs:166, test :581-597), preserving the dig-node#273 distinction.
  6. No secrets, no custody, no unsafe widening. No key material, signing or spend construction in
    the delta. New logging is a peer id and two data-free install lines. Node::p2p_content widens
    pub(crate) to pub (download.rs:2547); its new mutator set_bond_verifier (:1554-1559) is
    OnceLock::set — idempotent, one-way, first-call-wins — so a running node's verification posture
    cannot be swapped. Installation is gated on enable_chain_sync (server.rs:2176).
  7. Cache growth is bounded and not attacker-steerable. MAX_CACHED_VERDICTS = 1024
    (bond_verify.rs:61) evicts one entry rather than clearing (:352-359) — the right choice, since
    clearing would let coin-id rotation discard earned verdicts. The victim comes from HashMap
    iteration under a per-process RandomState seed, so it is not attacker-selectable. Lock poisoning
    is handled without panicking (:276, :349).

Scope I did NOT cover, stated plainly

I did not run a cold cargo build or a revert-probe: this machine is at 34 GB free (99% used) and
a fresh target dir for this dependency set risks ENOSPC against live lanes. CI on this exact head is
fully green, including Test + coverage (15m13s) and Clippy, so the suite is measured — by CI, not
by me. Every finding above is established by reading the artifact, and F1's key step is corroborated
by the PR's own test at tests/mirror_bond_verify.rs:384-412.

Separately, and not a security matter: the PR is BEHINDorigin/main is now f280cbf1 while the
merge-base here is a5507dbf. A re-merge will move the head, and this verdict is against
d3a6ae14 only.

MichaelTaylor3d and others added 2 commits August 31, 2026 23:01
… a discarded one

Two gate findings on #467, plus the two doc claims that codified them.

The verdict cache was keyed on `(coin id, store, root, epoch)` while `verdict_for`
answers a peer-DEPENDENT question. A `Bonded` earned by a coin's real holder would
have been served, for the whole 600s TTL, to any stranger republishing that public
coin id -- reinstating through the memo layer the substitution the ownership half
exists to refuse. The claiming peer id is now part of the key, hashed so the key
stays fixed-size and `Copy` against an attacker-chosen string. Caching `Bonded` is
retained deliberately: refusing to cache it would trade this for unbounded chain
reads.

While `peer_declaration` has no typed source, `Bonded` is unreachable for every
input, so the chain reads were paid at a third party for a verdict the credit-only
stable sort provably discards -- up to 32 uncacheable `api.coinset.org` reads per
locate, admitted by one token of a bucket sized for a cheap lookup. `verdict_for`
and the production verifier now short-circuit to `Unverified` before any read. The
gate is a probe of `peer_declaration` itself rather than a separate flag, so it
lifts when 0.8.0's accessor arrives with no second switch to remember, and an
accessor needing more than the term list leaves it closed.

The ownership half no longer re-fetches the coin the chain half just read, so a
bonded holder costs two reads rather than four once promotion is live.

Also: `cached_epoch()`'s hint is gone. Its stated invariant was false across a
rollover -- the key was built FROM the stale epoch, so it hit the entry stored
under the previous one. `current_epoch_now()` is clock arithmetic, so the cache is
now probed under the true epoch and only the epoch-record parse is deferred to a
miss. `declared_peer`'s two false claims (unreachable in production; memoised one
layer up) are gone with the function.

SPEC.md 25.6a specified the peer-agnostic key normatively and now specifies the
claimant, the true-epoch probe, and the short-circuit.

Refs: #466

Co-Authored-By: Claude <noreply@anthropic.com>
@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: e8b9041
  • version: 0.221.0 (still above main's 0.217.0)
  • mergeStateStatus: BLOCKED (draft + checks pending), no longer DIRTY

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

Conflicts were Cargo.toml + Cargo.lock only; lock taken from origin/main.
Also fixes the Clippy break on e8b9041: bond_verify.rs current_requirement()
returned a let binding (clippy::let_and_return, -D warnings).
cargo update -w --offline; CI runs --locked, so the lock must carry the
workspace members' own versions.
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Independent correctness gate — IN PROGRESS, not the verdict

Head read: 11b3dd2ec5aa3c19d175e8027ec2a3dceb8d23ce (resolved from the remote; matches the brief).
Own detached worktree at C:/tmp/gate466-rev; no sibling worktree touched.

Confirmed so far, against code rather than the comments asserting it:

  1. Round-2 fix — claimant in the cache key: LANDED. VerdictKey carries
    claiming_peer: [u8; 32] = SHA-256(claiming_peer_id)
    (crates/dig-node-service/src/mirror/bond_verify.rs:82 and :88-103), so a Bonded earned by a
    coin's real holder cannot be served to a stranger republishing the same public coin id.
  2. Round-2 fix — SPEC.md no longer codifies a peer-agnostic key: LANDED. §25.6a states the key
    as (coin id, store, root, epoch, claiming peer id) and says why each component is load-bearing.
  3. Round-2 fix — verdict_for short-circuits BEFORE the chain read: LANDED.
    bond_verify.rs:306 returns Unverified on !declaration_source_is_readable() ahead of
    chain_bond_verdict_and_coin; the async verify impl repeats the same skip at :484 before the
    cache probe, epoch read and requirement parse.
  4. The gate lifts itself, no flag: CONFIRMED. declaration_source_is_readable()
    (bond_verify.rs:167) probes through the real peer_declaration with
    dig-peer:<64-hex> for a probe peer and compares against DeclaresThisPeer. There is no env var
    and no second switch; replacing peer_declaration's body flips both short-circuits.
  5. Credit-only ranking: CONFIRMED at the type level. credit_rank
    (crates/dig-node-core/src/mirror_bond.rs:127) maps Unverified | Unbonded to the same tier and
    the sort is sort_by_key (stable, mirror_bond.rs:203), so no pointer a stranger attaches can
    rank an honest holder below where no pointer would have put it. BondVerdict deliberately does
    not derive Ord.

Still open: test-vacuity probes with counts, the MAX_VERIFIED_PER_LOCATE artifact bound, and the
vacuity-honesty question (does the PR/SPEC/module doc read as though collateral is now enforced).

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (round 3)

Auditing 11b3dd2ec5aa3c19d175e8027ec2a3dceb8d23ce (resolved from gh pr view 467 --json headRefOid;
matches the dispatch brief). Merge-base 1e94c7f0141139834164f389e34886c2618da0ed. Read from my own
detached worktree; no sibling checkout touched.

Settled so far — recording these now so they survive an interruption.

4. Hint-equality check — CLEAR. dig-mirror-coin 0.7.0 coin.rs:148-168: advertises is
self.declared == asked && self.namespace_hint == mirror_hint(store, root, self.owner_puzzle_hash(), epoch). Exact struct equality on the declared triple, and the hint is recomputed with the owner from
coin.rs:93 / :325inner.proof.parent_inner_puzzle_hash, i.e. the coin's own lineage proof,
executed on-chain code, not a memo and not a claimant-supplied value. No arithmetic recompute of the
morph anywhere in the PR. bond_verify.rs:267 calls it and nothing else re-derives a hint.

No Ord on the verdict — CLEAR. mirror_bond.rs:63 derives exactly
Debug, Clone, Copy, PartialEq, Eq. Ranking goes through credit_rank (mirror_bond.rs:130-135),
which is total and two-valued: Bonded -> 0, Unverified | Unbonded -> 1. Sort is
sort_by_key (mirror_bond.rs:210), which is stable, so within-tier source order is preserved.

chain_bond_verdict bypass — CLEAR. It is pub and does skip declaration_source_is_readable,
but a repo-wide grep for chain_bond_verdict|verdict_for|declaration_source_is_readable|peer_declaration
returns exactly one non-defining file: crates/dig-node-service/tests/mirror_bond_verify.rs
(lines 19, 176, 389, 401). Zero production callers. The only production path into
chain_bond_verdict_and_coin is verdict_for (bond_verify.rs:309), which is gated at :306.

Gate placement — CLEAR on both legs. The short-circuit fires before any chain read at
bond_verify.rs:306 (verdict_for) and again, earlier and cheaper, at bond_verify.rs:484
(ChainBondVerifier::verify) — before settled_epoch(), before the cache probe, before
current_requirement()'s file read. So an inert node pays no I/O of any kind per record.

Still open: amplification re-measurement under an activated build, cache-as-weapon, the inert-state
trap, failure direction, and a full sweep for a subtract-credit path (budget exhaustion, dedup,
truncation). Verdict to follow on this PR.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (round 3, part 2)

Head 11b3dd2ec5aa3c19d175e8027ec2a3dceb8d23ce. Continuing from the previous comment.

1. Can hearsay subtract credit? — CLEAR, with one named residual that does NOT subtract

I traced every consumer of the ranked slate, not only the sort.

  • BondRankingLocator::find_providers (mirror_bond.rs:161-215) — two tiers, stable sort_by_key,
    nothing dropped, budget-skipped records pushed at credit_rank(Unverified) = baseline.
  • NodeContent::walk_for_providers (download.rs:2092-2100) — ranked, then retain_excluding_self.
    Filter is by this node's own peer id, verdict-independent.
  • locate_holders (download.rs:1787-1820) — the ranked DHT leg is FIRST-HAND; forwarded hearsay is
    appended and merge_answers caps only the hearsay portion, so a ranked record can never be
    displaced by a forwarded one. dedup_by_peer_tagged (download.rs:3063) keeps the FIRST
    occurrence, and first-hand precedes hearsay, so a hearsay duplicate cannot displace a ranked one.
  • holder_cache (holder_cache.rs:71,78 — 300s TTL / 4096 keys) memoises the ranked slate; hit or
    miss is verdict-independent.

Order does become INCLUSION downstream, which is worth stating plainly because it is not obvious
from mirror_bond.rs: redirect_error_object (download.rs:3046) and providers_json
(download.rs:3094) both .take(MAX_REDIRECT_PROVIDERS), and MAX_REDIRECT_PROVIDERS == 8
(download.rs:115 -> dig_dht::MAX_ADDRESSES_PER_RECORD, dig-dht-0.15.0/src/record.rs:165).
So promotion decides who is disclosed, not merely who is listed first.

I still find no subtraction. In every construction I tried, a holder carrying a hostile pointer
lands exactly where the same slate with the pointer stripped puts it, because both are
credit_rank == 1 and the sort is stable. The truncation is applied to a list whose baseline order
is the source's, so a disproven pointer cannot push a holder across the cut that a no-pointer record
would have survived.

Residual (advisory, post-activation only): budget exhaustion is a promotion-DENIAL primitive.
MAX_VERIFIED_PER_LOCATE == 8 (mirror_bond.rs:126) equals MAX_REDIRECT_PROVIDERS == 8. Publishing
8 Sybil provider records each carrying any 32 junk bytes, ordered ahead of an honest bonded holder,
consumes the whole budget (mirror_bond.rs:180-186) so the honest holder is never read and stays at
baseline — and therefore falls outside the 8-record redirect cut it would have been promoted into.
This never puts the holder below the pre-PR order, so it is not the demotion primitive round 1 found;
it nullifies the feature for that holder. The module doc's "declining to compute it for the tail
costs a holder nothing it was owed"
(mirror_bond.rs:45) understates this: with the redirect
truncation in play, a denied promotion can be a denied disclosure. Not gating (the layer is inert),
but the activation lane should size the budget against MAX_REDIRECT_PROVIDERS deliberately rather
than by coincidence.

6. Failure direction — CLEAR, every guard fails to baseline

Every error path lands on Unverified or Unbonded, both of which are credit_rank == 1, i.e. the
located order unchanged. Enumerated:

condition site verdict effect
chain source unavailable bond_verify.rs:397-403 Unverified baseline
coin_record errors :230 Unverified baseline
coin_spend missing or errors :253 Unverified baseline
MirrorError::ChainUnavailable :260 Unverified baseline
epoch not settled (rollover) :487-489 Unverified baseline
no censused requirement :275 Unverified baseline
malformed memo :263 Unbonded baseline
no such coin / spent / wrong puzzle hash / not $DIG / wrong tuple :229,236,242,259,268,273 Unbonded baseline
store-granularity ContentId :479-481 Unverified baseline
verifier never installed mirror_bond.rs:167-169 pass-through slate untouched
locate failure mirror_bond.rs:165 ? propagates BestEffort::source_failed, absence NOT asserted

Nothing refuses, drops or blocklists a peer on any verdict. A partitioned node returns the slate
exactly as located. Unverified is never cached (:350-352), so an outage does not persist past its
own duration.

Still open: amplification re-measurement, cache-as-weapon, the inert-state trap, and the test run.

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CHANGES-REQUIRED — independent correctness gate

Head read: 11b3dd2ec5aa3c19d175e8027ec2a3dceb8d23ce (resolved from the remote myself; matches the
dispatch brief). Own detached worktree at C:/tmp/gate466-rev; no sibling worktree or target/ touched.
Tree restored and verified clean after the revert probe.

The code passes. Both gating findings are TEXT, not code — a paragraph in SPEC.md and the closing
keyword. Do NOT "fix" this by deleting the short-circuit or weakening the ownership gate: withholding
credit until #473 lands is the right posture and this review endorses it.

Is it correct to merge a provably inert layer? Yes.

Both alternatives are worse. Promoting on the chain half alone is the round-1 HIGH finding — a coin id is
published in cleartext, so any stranger answering a lookup can attach an honest holder's real coin id to a
record carrying its own addresses and rank first at zero collateral. Performing the chain reads anyway,
for a verdict credit_rank provably discards, converts one cheap DHT lookup into attacker-directed egress
at the chain provider — the same transport this node's wallet reads through. Withholding credit from
everyone is the only posture that is neither exploitable nor costly, and it costs zero on every input.
Merging also makes #473 a one-function-body change rather than a second design round.

Verified against code, not against the comments asserting it

  • Round-2 fix, claimant in the key — LANDED and load-bearing. VerdictKey.claiming_peer is
    SHA-256(claiming_peer_id) (crates/dig-node-service/src/mirror/bond_verify.rs:82, :88-103).
    Revert probe on this head: replacing the hasher update with a discard gives
    test result: FAILED. 3 passed; 1 failed; 747 filtered out
    a_verdict_earned_by_one_peer_is_not_served_to_another panics at bond_verify.rs:751 with
    left: Some(Bonded) / right: None. Four tests ran, so it is a real red, not a filter matching nothing.
    Restored.
  • Round-2 fix, SPEC no longer codifies a peer-agnostic key — LANDED. Section 25.6a states
    (coin id, store, root, epoch, claiming peer id) and why each component is load-bearing.
  • Round-2 fix, verdict_for short-circuits before the chain read — LANDED. bond_verify.rs:306,
    ahead of chain_bond_verdict_and_coin; the async verify repeats it at :484 before the cache probe,
    epoch read and requirement parse.
  • The gate lifts itself — no flag, no second edit. declaration_source_is_readable()
    (bond_verify.rs:167) probes through the real peer_declaration. No env var anywhere in the diff.
    no_visible_term_promotes_a_claim_before_the_typed_accessor_exists (bond_verify.rs:804) asserts
    assert_ne!(..., DeclaresThisPeer) over four term sets INCLUDING the matching dig-peer:{peer}, so any
    accessor that starts answering that term genuinely FAILS it. A tripwire, not a tautology.
  • Credit-only ranking — a discriminating test, not an assertion.
    a_bogus_pointer_leaves_an_honest_holder_exactly_where_no_pointer_would
    (crates/dig-node-core/src/mirror_bond.rs:400) runs the same slate twice, with and without the bogus
    pointers, and compares orders. A three-tier lattice fails it. BondVerdict not deriving Ord makes a
    future sort on the verdict a compile error.
  • MAX_VERIFIED_PER_LOCATE = 8 bounds the ARTIFACT. a_locate_reads_at_most_the_budget_off_the_chain
    (mirror_bond.rs:438) drives the real BondRankingLocator with a 40-record slate and reads the
    verifier's own call counter: 8 reads, all 40 records still returned.
  • Step orderan_uncensused_node_still_catches_the_lie_but_will_not_certify_the_truth covers it:
    the binding is checked before collateral magnitude.
  • Wiring is real. BondRankingLocator is installed inside NodeContent::new (download.rs:1295)
    outside every other locator, and the engine test drives the real constructor.
  • Suite: cargo test -p dig-node-service --test mirror_bond_verify gives
    10 passed; 0 failed; 0 filtered out.
  • 2.4b: holding chia-* at the 0.36 line is right here — dig-mirror-coin 0.7 and chia-query
    compile against it, and moving chia in this crate alone would ship it split across two chia lines, the
    exact defect 2.4b exists to prevent. dig-* are at latest published.

GATING

G1 — SPEC.md:8284 reports a capability the running node does not have. The status bullet says
"section 25.10's verification of OTHER peers' claims is implemented", and 25.6a is written in
normative present tense ("A node that LOCATES a holder verifies that holder's claimed bond and promotes a
proven one"). The vacuity appears only as a CONDITIONAL later ("While the node has no sound source for the
coin-to-peer binding..."), and nothing in SPEC.md says that condition IS the current state. A reader
cannot distinguish satisfied from vacuously satisfied — precisely what CLAUDE.md 2.0 forbids: a spec that
cannot tell the two apart reports a capability the system does not have. The PR body states it superbly;
the PR body is not the durable record.

Fix: one sentence in the 25.10 status bullet — the coin-to-peer binding has no source on a shipped node
today, bonded is therefore unreachable, no chain is read, and dig-node#473 owns the activation. Do not
weaken 25.6a's normative clauses; they are correct as the contract.

G2 — Closes #466 closes a ticket whose stated acceptance is not true of a running node. #466 asks
that a peer advertising a bond it does not hold is "detected on a production path". On this head it is
not: verdict_for returns Unverified before any chain read on every input. Your reasoning for testing
at chain_bond_verdict is sound — through verdict_for every acceptance test would return Unverified
and be indistinguishable from a broken verifier — but the consequence is that the acceptance is proven of
the mechanism and its wiring, one level below where production stops. Merging as-is records collateral
enforcement as delivered when it is not, and that mis-statement outlives the PR.

Fix, either is fine: (a) drop Closes #466 and leave it open with #473 as its blocker, or (b) keep it and
in the SAME unit of work comment on #466 re-scoping its acceptance explicitly to mechanism + wiring, with
#473 named as owner of the runtime half. The closure is immediate on merge, so not "later".

ADVISORY — not blocking

  • crates/dig-node-core/src/mirror_bond.rs:1-45 — the module a reimplementer opens first never mentions
    that Bonded is unreachable on a shipped node. bond_verify.rs says it plainly; this is the public
    crate and does not.
  • crates/dig-node-core/src/download.rs — the engine test is named ..._ranks_a_disproven_bond_last_...
    and its final assertion message says a disproven claim is "demoted", both contradicting the module's
    central property that nothing is ever demoted. The record is last because it was located last. That test
    also does not discriminate credit-only from a three-tier lattice (both yield 9,7,8); mirror_bond.rs:400
    is what carries that property. Rename, so a later reader does not "fix" the lattice toward the name.
  • chain_bond_verdict is pub with no production caller. Justified in its doc and correct given G2 —
    noted so a dead-code sweep does not delete a deliberately-exposed seam.
  • crates/dig-node-core/src/peer.rs:858 — the dig-gossip pinning note is correct and useful but outside
    #466's blast radius. No objection; recording that I read it as deliberate.

Not merged, not undrafted.

Comment thread SPEC.md Outdated
Comment thread crates/dig-node-service/src/mirror/bond_verify.rs
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (round 3, part 3)

Head 11b3dd2ec5aa3c19d175e8027ec2a3dceb8d23ce.

2. Amplification, re-measured — TODAY it is ZERO; post-activation it is 16 reads + 8 CLVM runs

Today, per admitted request token: zero outbound reads, on every input. Two independent gates:

  • ChainBondVerifier::verify (bond_verify.rs:484) returns before settled_epoch(), before the
    cache probe and before current_requirement()'s file read — so an inert node pays no I/O of any
    kind per record, not merely no network I/O.
  • verdict_for (bond_verify.rs:306) returns before chain_bond_verdict_and_coin.

Asserted, not inferred: nothing_is_read_from_the_chain_while_the_declaration_has_no_source
(bond_verify.rs:757-793) drives a CountingChain whose every method increments, and asserts
reads == 0 and verdict == Unverified and !declaration_source_is_readable() as a control.
The double answers nothing, so a short-circuit that merely failed fast could not pass it.

chain_bond_verdict remains pub and ungated, but has zero production callers (grep in my first
comment). Not a bypass.

Post-activation worst case per locate, which the activation lane must own:

cost per record per locate (budget 8)
ChainSource::coin_record 1 8
ChainSource::coin_spend 1 8
outbound HTTPS to api.coinset.org 2 16
current_requirement() epoch-record file read + line-parse 1 8
CLVM execution of an attacker-selected creating spend 1 8

The reads go to api.coinset.org FIRST — ChainTransport::chain_source (dig-wallet
sage/chain.rs:307-334) documents coinset_fallback_enabled as the production default. Round 1's
~32 becomes 16 because chain_bond_verdict_and_coin returns the coin it read rather than re-fetching.

One cost the current analysis does not name. bond_verify.rs:158-160 frames the price as "four
third-party HTTPS reads per holder"
and nothing else. But step 2 runs the coin's parent puzzle:
MirrorCoin::from_creating_spend -> classify -> chia_sdk_types::run_puzzle
(dig-mirror-coin-0.7.0/src/coin.rs:290), and run_puzzle is bounded at
MAINNET_CONSTANTS.max_block_cost_clvm (chia-sdk-types-0.36.0/src/run_puzzle.rs:18) — a full
block's CLVM budget per execution. The attacker chooses the coin id, therefore which spend gets
executed, therefore how expensive that execution is; and the claiming peer id is part of the cache
key, so N Sybil peer ids naming one deliberately-expensive coin cost N executions rather than one.
It runs under block_in_place (bond_verify.rs:407), so it occupies a blocking-pool thread rather
than an async worker. Bounded per locate, and inert today — but the doc at bond_verify.rs:139-144
instructs the activation to revisit "the cost analysis on declaration_source_is_readable", and that
analysis as written omits the CPU term entirely.

Bounding upstream is real and worth recording: locate_holders consults holder_cache
(download.rs:1764, TTL 300s / 4096 keys) before walking, so one content id costs one locate per
300s. fetch_resource's extra find_providers (download.rs:2146) bypasses that cache but is
gated on tracing::enabled!(DEBUG).

3. Cache as a weapon — CLEAR on correctness, one bounded thrash

  • Cross-peer serving: closed. VerdictKey (bond_verify.rs:75-84) carries
    SHA-256(claiming_peer_id); a_verdict_earned_by_one_peer_is_not_served_to_another
    (bond_verify.rs:723-750) asserts the stranger read returns None with the same-peer read as the
    control. Hashing keeps the key Copy and fixed-size against an attacker-chosen string.
  • Only definite verdicts cached: enforced. remember (bond_verify.rs:349-352) returns early on
    Unverified, so an outage is never held past its own duration.
  • Bound honoured; overflow evicts one. bond_verify.rs:356-363. len() is checked before insert
    so the map cannot exceed 1024. The victim is entries.keys().next() over a RandomState HashMap
    — not attacker-selectable in any targeted way, and a wrong guess costs one chain read, never a
    wrong answer.
  • TTL cannot be extended. remember always stamps Instant::now() and get never refreshes, so
    there is no sliding window. Worst case is a Bonded surviving <=600s after the coin is spent —
    promotion-only, and 600s against a 7-day epoch.
  • Epoch rollover cannot serve a wrong-epoch verdict. settled_epoch() (bond_verify.rs:444-449)
    is clock arithmetic on the TRUE current epoch, and the epoch is in the key.

Minor (advisory, post-activation). get filters expired entries but never removes them, and
remember does not purge either — so expired entries occupy slots and count toward the 1024 bound. A
stranger can fill the map with Unbonded entries at one chain read each (a nonexistent coin id
returns Ok(None) at :229 and is cached), leaving the cache mostly-expired at steady state. The
effect is a higher miss rate, i.e. more chain reads — an amplification multiplier, never a wrong
answer, and bounded by MAX_VERIFIED_PER_LOCATE per locate. A purge-on-insert would remove it.

Still open: the inert-state trap judgement and the revert probes. Verdict next.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-decider (adversarial) — VERDICT: MERGE WITHOUT Closes #466

Audited head 11b3dd2ec5aa3c19d175e8027ec2a3dceb8d23ce, in a private detached worktree
(worktrees/dn-adv-467). No sibling worktree touched, no sibling target/ removed.

Method: grep + direct read + two mutation probes. gitnexus was NOT used — the dig-node index is
~301 commits stale and impact returns a false-safe impactedCount: 0 rather than erroring, so a
zero from it would not have been evidence.

Baseline, real counts (a filter matching nothing exits 0 printing running 0 tests):

run result
cargo test -p dig-node-service --test mirror_bond_verify 10 tests, 10 passed, 0 filtered out
cargo test -p dig-node-service --lib mirror::bond_verify 4 tests, 4 passed, 747 filtered
cargo test -p dig-node-core --lib mirror_bond 8 tests, 8 passed, 1051 filtered
cargo test -p dig-node-core --lib the_engine_ranks_a_disproven_bond_last 1 test, 1 passed, 1058 filtered

Claim 1 — "shipping an inert layer is the right call" — STANDS as an engineering call, REFUTED as a reason to close #466

The premise is measured and true. index.crates.io/di/g-/dig-mirror-coin lists 0.3.1 / 0.4.0 /
0.5.0 / 0.7.0 — no 0.8.0. peer_declaration (bond_verify.rs:143) returns NotReadable
unconditionally, declaration_source_is_readable() (:174) probes through it, and both
verdict_for (:206) and ChainBondVerifier::verify (:482) short-circuit on it before any cache
probe, epoch read, disk read or chain read.

What I attacked and could not break:

  • "Dead code that will rot." Not dormant in the dormant-code sense — the layer IS installed in
    production (NodeContent::new, download.rs:1299-1301, reached from for_dht at :1523), and
    the ranking is exercised by 8 core tests through the real BondRankingLocator. Only the chain
    decision
    is short-circuited. Rot needs unexercised code; this is exercised.
  • "A trap for the maintainer who flips one function body." The case I most expected to win, and it
    fails. no_visible_term_promotes_a_claim_before_the_typed_accessor_exists (bond_verify.rs:804)
    puts a well-formed dig-peer:<64hex> term in its fixture, so it FAILS the moment the body is
    replaced. And the gate is the condition itself: declaration_source_is_readable calls the
    production function on the most favourable input, so there is no second switch to forget.
  • "The SPEC will be born false." It is not. §25.6a states the posture normatively and
    permanently
    "a node that cannot read such a declaration MUST NOT promote" — which stays true
    after activation. No born-false claim found.

Where it is refuted: the closing keyword.

#466's acceptance is "A peer advertising a bond it does not hold is detected on a production
path
"
, and its scope says outright "the acceptance is a call site, not a function." On a
production path today verdict_for returns Unverified for every input, including a peer
advertising a bond it does not hold. The PR body concedes it in its own table ("it detects a false
claim on a real node — no"
) and states chain_bond_verdict "is pub for exactly this reason and
has no production caller."

Closes #466 would therefore close a ticket on an acceptance criterion that is measurably unmet, by
that ticket's own standard — the vacuous-conformance class: a closed ticket asserting a capability
the node does not have.

The opposite failure is real and I weighed it. The branch is correct, inert, specced and tested;
holding it open through more main merges costs rebase churn, a re-bump (a --onto rebase silently
DROPS a colliding version bump) and further gate rounds on a diff that would not change. That is why
the answer is merge, not hold — the fix is one keyword, not a branch-lifetime decision.

One overclaim to correct, non-gating. "it costs anything today — no, zero, on every input" is
not literally true: per record carrying a pointer, declaration_source_is_readable runs a
"00".repeat(32) and a format! (two String allocations), and BondRankingLocator builds a Vec
and runs a stable sort on every locate. Zero chain reads, zero disk reads, no reordering in effect —
but not zero work. Say "no chain read, no disk read, no reordering" and it is true.


Claim 2 — "credit-only ranking makes hearsay safe to act on" — STANDS

I attacked this hardest and could not produce a path where attacker-supplied input leaves an honest
holder worse off than the no-pointer baseline.

Structural argument, verified in code. credit_rank (mirror_bond.rs:130) is a function of one
record's own verdict; the sort is sort_by_key, STABLE (:203). Unverified and Unbonded share
rank 1. The only cross-record coupling is the MAX_VERIFIED_PER_LOCATE = 8 budget
(:172-179), and skipping a record can only move it from rank 0 to rank 1 — it can only withhold
promotion, never impose a rank below baseline. An honest holder can be denied credit; it can never be
pushed below a non-promoted peer.

The budget attack I built, and why it is not a refutation. Post-activation, an attacker answering
one lookup (stop_on_providers = true makes one answer the whole slate) places 8 pointer-carrying
records ahead of an honest bonded holder; the budget is exhausted and the honest holder is never
verified, so never promoted. That is a free, remote credit-denial primitive, and it means the
mechanism offers no guarantee against precisely the adversary it was built for. But it sits inside
the claimed model, and the honest holder's absolute standing is unchanged. A capability gap, not a
harm. Worth stating on #473 so activation does not inherit a defence that is trivially deniable.

Cache eviction. remember (:249) evicts ONE arbitrary entry via entries.keys().next(), not
clear() — the earlier round's amplifier is gone. The victim is not attacker-selectable: HashMap's
RandomState is seeded per map. Unverified is never cached (:250), so an outage cannot be
latched.

Mutation probe — the property is load-bearing, not decorative. I replaced credit_rank's
collapsed arm with a three-tier lattice (Unbonded => 2):

running 8 tests
test mirror_bond::tests::an_unreachable_chain_is_unverified_not_unbonded ... FAILED
test mirror_bond::tests::a_bogus_pointer_leaves_an_honest_holder_exactly_where_no_pointer_would ... FAILED
test result: FAILED. 6 passed; 2 failed; 0 ignored; 1051 filtered out

Mutation reverted; the worktree is clean.


Claim 3 — "the algorithm is SYSTEM.md's, not invented" — STANDS

I traced every value reaching a comparison. No claimant-supplied value reaches one.

  • store_launcher_id / root_hash come from bondable_tuple(content) (bond_verify.rs:419) — the
    ContentId the caller asked about, not the record.
  • epoch comes from settled_epoch() (:436), this node's own clock.
  • owner is self.inner.proof.parent_inner_puzzle_hash (dig-mirror-coin-0.7.0/src/coin.rs:93) —
    the lineage proof, never a memo.
  • The only claimant-supplied value is claimed_coin_id, used solely as a lookup key and then bound
    by candidate(coin_id), which matches the child by a coin id hashing parent_id, puzzle hash and
    amount. read_parent_outputs additionally rejects a substituted puzzle reveal by tree-hashing it
    against creating_spend.coin.puzzle_hash.
  • advertises (coin.rs:150) is self.declared == asked && self.namespace_hint == mirror_hint(...)
    exact equality on both halves, with no arithmetic recompute substituted for the binding.

The freely-chosen-epoch attack, worked through.
a_freely_chosen_epoch_solves_onto_any_other_advertisements_hint (namespace.rs:198) proves the
hint alone is forgeable: the epoch term absorbs the difference between two advertisements. But check
1 pins declared.epoch == asked epoch, and the asked epoch is this node's local settled epoch (a
small integer) while the solved epoch is a ~256-bit-scale value. The collision cannot survive check

  1. Both halves are genuinely non-redundant, and the prior round's mutation dropping the hint half
    already failed a_declaration_that_disagrees_with_its_own_hint_is_unbonded, still present and green
    at this head.

One thing that looks like a hole and is not. An attacker CAN mint a real, fully collateralised
coin advertising someone else's (store, root) at the current epoch under its own owner, and it
passes all four steps. That is not an attack — mirroring is permissionless and the attacker paid real
$DIG. The residual is coin-is-not-bearer, which is exactly what peer_declaration / #473 closes and
what the short-circuit currently refuses to guess at.


Claim 4 — "the tests are load-bearing" — STANDS, with one stale test that must not be relied on

The concern is real but lands on the wrong file. The split:

  • The chain tests (tests/mirror_bond_verify.rs, 10) do sit below verdict_for's short-circuit,
    calling chain_bond_verdict. That is stated in the PR rather than hidden, and it is the only level
    at which the four steps are observable today. Not vacuous — the prior round mutated two of them red.
  • The ranking tests (dig-node-core/src/mirror_bond.rs, 8) drive the real
    BondRankingLocator with a mock verifier, so they run at the production level and are NOT
    short-circuited. My mutation broke two of them, which settles the question.
  • a_coin_that_passes_every_chain_check_is_still_not_promoted_to_a_claimant
    (tests/mirror_bond_verify.rs:384) asserts BOTH halves on ONE fixture — chain_bond_verdict says
    Bonded, verdict_for says Unverified — so the second assertion cannot be satisfied by a broken
    chain. Good construction.

Finding (non-gating): the_engine_ranks_a_disproven_bond_last_on_its_own_discovery_path
(download.rs:4452) is insensitive to the security property its name asserts.
Under my three-tier
mutation it still PASSED (running 1 test ... 1 passed), because with input order 7,8,9 and
expected 9,7,8 a three-tier lattice yields the identical answer. Its name says "ranks a disproven
bond last" and its second assertion message says "a disproven claim is demoted on the redirect
path"
— both stale wording from the pre-credit-only design, and both contradict the FIRST assertion
message on the same test. The job it really does (proving the layer is wired into the production
constructor) it does do; I confirmed for_dht (download.rs:1523) calls Self::new, which installs
BondRankingLocator outermost. Rename it and fix the second assertion message, or a future
reader will believe credit-only is covered there when it is covered only in mirror_bond.rs.

Carried forward, unchanged and non-gating: spawn_bond_verifier_install (bond_verify.rs:517) gives
up after 120 s with only a tracing::debug! and has no test. Harmless while the layer is inert; it
becomes a silent no-verifier failure the moment #473 activates it, so it belongs on #473's checklist.


Recommendation: MERGE WITHOUT Closes #466

The code is correct, genuinely inert, normatively specced, and its two security properties
(credit-only ranking; both halves of advertises) are proven load-bearing by mutation. Holding the
branch open buys nothing and risks a silently dropped version bump on the next rebase.

But #466's acceptance — detection on a production path — is not met, and the PR body says so
itself. Before merge:

  1. Change Closes #466 to Refs #466. This is the gating item.
  2. Put the activation obligations on Nothing binds a mirror coin's owner to a DHT peer id — verification cannot name a claimant #473 so they cannot land separately: replace
    peer_declaration's body, keep the claiming peer in VerdictKey, add the authoritative-record
    restriction, re-size and re-verify the miss-lookup budget against the now-live chain reads, note
    the budget-exhaustion credit-denial above, and test spawn_bond_verifier_install. Nothing binds a mirror coin's owner to a DHT peer id — verification cannot name a claimant #473 closes
    both tickets.
  3. Optional and cheap, in this PR: fix the two stale strings on
    the_engine_ranks_a_disproven_bond_last_on_its_own_discovery_path, and soften the body's "costs
    zero on every input" to "no chain read, no disk read, no reordering".

Items 2 and 3 are hygiene. Item 1 is the one that decides the gate.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict (round 3, part 4)

Head 11b3dd2ec5aa3c19d175e8027ec2a3dceb8d23ce.

5. The inert state — genuinely zero-risk today, and the guard rails are SUFFICIENT

Confirmed inert on two independent axes:

  • peer_declaration (bond_verify.rs:145-150) returns NotReadable for every input, so
    declaration_source_is_readable() is false and both short-circuits fire.
  • spawn_bond_verifier_install is called only under config.enable_chain_sync
    (server.rs:2176-2181), and NodeContent's slot is a pass-through until set
    (mirror_bond.rs:167-169).

dig-mirror-coin latest published is 0.7.0 (verified against the index); there is no 0.8.0.

The trap question is the right one to ask, and I judge the rails adequate. Four mechanisms, each
catching a different half of the activation:

  1. The gate is the condition, not a flag. declaration_source_is_readable()
    (bond_verify.rs:167-171) probes the REAL peer_declaration on the most favourable input. There
    is no second switch to forget, and an accessor needing more than the term list would change
    peer_declaration's signature — which breaks this call and forces an explicit decision at
    compile time rather than silently. Both failure directions are safe: a probe that stays false
    withholds credit; a probe that flips without a sound source is caught by (3).
  2. no_visible_term_promotes_a_claim_before_the_typed_accessor_exists (bond_verify.rs:795-810)
    fails on ANY activation, including a correct one — deliberately, so the maintainer must open the
    file where bond_verify.rs:139-144 lists the three obligations.
  3. a_coin_that_passes_every_chain_check_is_still_not_promoted_to_a_claimant
    (tests/mirror_bond_verify.rs:373-412) is the one that SURVIVES a correct activation and keeps
    guarding HIGH 1: with the claimant "aa"*32 absent from the coin's terms, a correct accessor
    still yields Unverified; an unconditional DeclaresThisPeer yields Bonded and the test goes
    red. This is the strongest rail — it is a permanent regression test, not a tripwire.
  4. an_honest_peer_id_with_attacker_addresses_is_not_promoted (bond_verify.rs:686-712) fails
    on a correct activation too, and its doc names the residual it is standing over. That is the
    tripwire for obligation 3.

The three obligations are recorded in two places, which is what makes them survive the squash:
bond_verify.rs:139-144 and a comment on the OPEN activation ticket
#473, which lists them verbatim. Verified by reading
473 directly, not by trusting the PR body.

The one gap I do want to name — SPEC.md, and it is cheap to close in this PR

SPEC.md §25.6a's paragraph "A coin id proves the bond, never the bearer" describes the exact
address-substitution attack — "a record may carry an honest holder's peer id, that holder's real
coin id, and the attacker's addresses"
— and then says:

Promotion therefore additionally requires the coin's own owner-written declaration of the
claiming peer_id, and a node that cannot read such a declaration MUST NOT promote.

The "therefore" does not follow, and the requirement it states does not close the attack it just
described.
The declaration binds coin -> peer_id. The attacker's record carries the HONEST peer id,
so a correct declaration check passes on it and the record — with the attacker's addresses — is
promoted. The paragraph then adds that the dialler is not a backstop, which is true and makes the
gap larger rather than smaller.

SPEC.md is the normative contract an independent reimplementation is built against (CLAUDE.md
§4.2), and §25.6a contains no authoritative-record MUST at all. A reimplementer following it
exactly, and correctly, ships the hole. The code comment and #473 both record the obligation; the
one document whose job is to be the contract does not.

This is advisory, not gating — nothing is promoted today, so nothing is exploitable — but the fix
is one paragraph in a file already in this diff, and it removes the most plausible route by which a
future activation reinstates HIGH 1's cousin. Recommended wording: state that promotion MUST
additionally require the record to be AUTHORITATIVE (signature- or mTLS-verified provenance), because
peer_id -> address is unauthenticated in a lookup-collected record, and that this MUST land with the
declaration source rather than after it.

Revert probes running now; verdict follows.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security: PASS

Audited head: 11b3dd2ec5aa3c19d175e8027ec2a3dceb8d23ce — resolved myself from
gh pr view 467 --json headRefOid at the start of the audit and re-resolved at the end; unchanged
across the whole round. Merge-base 1e94c7f0141139834164f389e34886c2618da0ed. Read from my own
detached worktree at C:/tmp/worktrees/sec467; no sibling checkout touched, no sibling target/
removed.

No gating security defect. Round 1's two HIGH findings and round 2's F1 are all closed in code,
and I proved each by mutation rather than by reading. Detail is in my four interim comments above;
this is the verdict plus the advisory list.


The three remedies are revert-proven, not merely present

Each mutation applied to exactly one occurrence (the helper refuses on any other count), tested, then
reverted; git status --porcelain after the run showed only my own untracked scratch dir.

probe mutation result
A — F1, the read amplifier deleted the declaration_source_is_readable() short-circuit in verdict_for (bond_verify.rs:304-308) nothing_is_read_from_the_chain_while_the_declaration_has_no_source FAILED, left: Unbonded / right: Unverified — the chain was reached. 3 passed / 1 failed
B — HIGH 1 at the cache layer replaced hasher.update(claiming_peer_id.as_bytes()) with a constant (bond_verify.rs:94-95) a_verdict_earned_by_one_peer_is_not_served_to_another FAILED, left: Some(Bonded) / right: None — the stranger inherited the holder's verdict. 3 passed / 1 failed
C — HIGH 2, the demotion primitive split credit_rank into three tiers (mirror_bond.rs:132) TWO tests failed — a_bogus_pointer_leaves_an_honest_holder_exactly_where_no_pointer_would and an_unreachable_chain_is_unverified_not_unbonded. 6 passed / 2 failed

Baselines, with counts read rather than exit statuses: dig-node-core --lib mirror_bond 8 passed /
0 failed / 1051 filtered
; dig-node-service --lib bond_verify 4 passed / 0 failed / 747
filtered
.

Area by area

Hearsay cannot subtract credit. BondVerdict derives no Ord (mirror_bond.rs:63);
credit_rank is total and two-valued (:130-135); the sort is stable sort_by_key (:210). I
traced every downstream consumer rather than the sort alone — walk_for_providers
(download.rs:2092), locate_holders (download.rs:1787-1820, hearsay appended and capped
separately so a forwarded record can never displace a ranked one), dedup_by_peer_tagged
(download.rs:3063, first-occurrence with first-hand ahead of hearsay), the holder_cache
(verdict-independent), and the .take(MAX_REDIRECT_PROVIDERS) truncations at download.rs:3046 and
:3094. Order does become inclusion at those two sites, but the baseline order is the source's, so a
disproven pointer cannot push a holder across a cut a no-pointer record would have survived.

Amplification is zero today, on every input. Two independent gates —
ChainBondVerifier::verify:484 (before epoch, cache, and the epoch-record file read) and
verdict_for:306 (before any chain call). Probe A shows the assertion is load-bearing.
chain_bond_verdict is pub and ungated but has zero production callers — repo-wide grep returns
only tests/mirror_bond_verify.rs.

Cache. Peer-scoped by SHA-256(claiming_peer_id) (bond_verify.rs:75-84, probe B);
Unverified never cached (:349-352); bound honoured with single-entry eviction over a RandomState
map, so the victim is not attacker-selectable and a wrong guess costs one read, never a wrong answer;
no sliding TTL; epoch taken from the true current epoch so a rollover cannot serve a wrong-epoch
verdict. Poisoned-mutex paths degrade to no caching, no panic.

Hint equality. dig-mirror-coin 0.7.0 coin.rs:148-168 — exact struct equality on the declared
triple and the hint recomputed with owner_puzzle_hash(), which is
inner.proof.parent_inner_puzzle_hash (:93, :325), the coin's own lineage proof. No arithmetic
recompute of the morph anywhere, and no claimant-supplied value enters it.

Failure direction. Every one of eleven error and unknown paths lands on Unverified or
Unbonded, both credit_rank == 1, i.e. the located order unchanged. Nothing refuses, drops or
blocklists a peer on any verdict. Enumerated with file:line in my second interim comment.

Code hygiene. No unwrap, expect, panic!, indexing or numeric cast anywhere in the
non-test portion of either new module.

Inert state. Genuinely zero-risk, and the guard rails are sufficient: a self-lifting probe that
cannot be forgotten and whose signature change would break the build, plus three tests that go red on
activation, one of which (a_coin_that_passes_every_chain_check_is_still_not_promoted_to_a_claimant)
is a permanent regression test that SURVIVES a correct activation. The three activation obligations
are recorded in two places — bond_verify.rs:139-144 and a comment on the open #473 — which is what
makes them survive the squash. Verified by reading #473 directly.


Advisory — none of these gate this merge

A1. SPEC.md 25.6a states a remedy that does not close the attack it describes. The paragraph
"A coin id proves the bond, never the bearer" describes the address-substitution attack (a record
carrying an honest holder's peer id, that holder's real coin id, and the attacker's addresses) and
then says promotion "therefore" requires the coin's owner-written declaration of the claiming
peer_id. That declaration binds coin to peer_id; the attacker's record carries the honest peer
id, so a correct declaration check passes on it and the record is promoted with the attacker's
addresses. 25.6a contains no authoritative-record MUST at all. Since SPEC.md is the contract an
independent reimplementation is built against (CLAUDE.md 4.2), a reimplementer following it exactly
ships the hole. The code comment and #473 both record the obligation; the one document whose job is to
be the contract does not. One paragraph, in a file already in this diff — worth doing here rather
than filing.

A2. Budget exhaustion is a promotion-denial primitive (post-activation).
MAX_VERIFIED_PER_LOCATE == 8 (mirror_bond.rs:126) equals MAX_REDIRECT_PROVIDERS == 8
(download.rs:115). Eight Sybil records carrying any 32 junk bytes, ordered ahead of an honest bonded
holder, consume the whole budget (mirror_bond.rs:180-186) so that holder is never read, stays at
baseline, and falls outside the 8-record redirect cut it would have been promoted into. It never lands
below the pre-PR order, so this is not round 1's demotion primitive — but the module doc's "declining
to compute it for the tail costs a holder nothing it was owed"
(mirror_bond.rs:45) understates it:
with the redirect truncation in play, a denied promotion can be a denied disclosure. Size the budget
against MAX_REDIRECT_PROVIDERS deliberately rather than by coincidence.

A3. The cost analysis omits the CPU term (post-activation). bond_verify.rs:158-160 prices
verification purely as third-party HTTPS reads. Step 2 also executes the coin's parent puzzle —
MirrorCoin::from_creating_spend to classify to run_puzzle
(dig-mirror-coin-0.7.0/src/coin.rs:290) — bounded at MAINNET_CONSTANTS.max_block_cost_clvm
(chia-sdk-types-0.36.0/src/run_puzzle.rs:18), a full block's CLVM budget per execution. The attacker
picks the coin id and therefore which spend is executed and how expensive it is; the claiming peer is
part of the cache key, so N Sybil peer ids naming one deliberately-expensive coin cost N executions.
Under block_in_place (:407), so a blocking-pool thread rather than an async worker. Since
bond_verify.rs:139-144 instructs the activation to revisit exactly this analysis, the omission would
propagate.

A4. Post-activation worst case, for the activation lane's record: per locate, 8 records times
(coin_record + coin_spend) = 16 outbound HTTPS reads to api.coinset.org (the production
default per dig-wallet sage/chain.rs:307-334), plus 8 current_requirement() epoch-record file
reads and 8 CLVM executions. Upstream bounding is real: locate_holders consults holder_cache
(300s TTL / 4096 keys) before walking, so one content id costs one locate per 300s — except
fetch_resource's find_providers at download.rs:2146, which bypasses that cache and is gated only
on tracing::enabled!(DEBUG).

A5. The verdict cache never purges expired entries (minor). get (bond_verify.rs:339-345)
filters on TTL but does not remove, and remember does not purge — so expired entries occupy slots
against the 1024 bound. A stranger can fill the map with Unbonded entries at one chain read each (a
nonexistent coin id returns Ok(None) at :229 and is cached), leaving the cache mostly-expired at
steady state. Effect is a higher miss rate, i.e. more chain reads — never a wrong answer, and bounded
by MAX_VERIFIED_PER_LOCATE per locate. A purge-on-insert removes it.

A6. NodeContent::p2p_content widened from crate-private to pub (download.rs:2589). Not
remotely reachable, and set_bond_verifier is one-way via OnceLock, so an in-process embedder gains
nothing it did not already have. Noted, not a finding.


What I did NOT cover

  • I did not run crates/dig-node-service/tests/mirror_bond_verify.rs (413 lines). The service lib
    build alone took 12m15s here and the integration binary is a separate link; I read all thirteen test
    bodies and judged their fixtures and controls, but the counts in this thread are the lane's, not
    mine.
  • No clippy, no fmt, no full-workspace suite — the correctness gate's scope.
  • gitnexus was not used. Its dig-node index is ~301 commits stale and impact returns a
    false-safe impactedCount: 0 on a stale index, so I did blast radius by ripgrep plus direct read,
    tracing every consumer of find_providers by hand. Recorded per CLAUDE.md 2.0 bound (2).

Read-only throughout: no edit to any tracked file, no merge, no undraft. The PR remains a draft, which
is correct.

Gate round 3 findings, all text or test-quality; no behaviour changes.

- SPEC.md status bullet said verification of other peers' claims 'is
  implemented' with no way for a reader to tell satisfied from vacuously
  satisfied. It now states that no claim is verified on a running node,
  and names dig-node#473 as what makes promotion reachable.
- SPEC.md 25.6a described the address-substitution attack and then gave a
  remedy that does not close it: the coin's declaration binds coin to
  peer_id, but the attacker's record already carries the HONEST peer id,
  so the check passes and attacker addresses are promoted on somebody
  else's bond. Adds the authoritative-record MUST that does close it, and
  names the residual an unrestricted implementation carries.
- mirror_bond's module doc now leads with the inertness rather than
  leaving a reader to infer it, and no longer implies a locate is free of
  all work while inert -- it is free of chain and disk I/O and of any
  reordering, not of allocation or the sort.
- the engine acceptance test could not tell credit-only from a three-tier
  lattice: with the slate ordered 7,8,9 both answer 9,7,8. The disproven
  record now precedes the merely-unverified one, so credit-only answers
  9,8,7 and a three-tier lattice answers 9,7,8. Its name and one message
  asserted the holder was 'demoted', which is the opposite of the property
  the module guarantees.

Refs #466
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Heads-up: this branch's version now COLLIDES with main — the green version gate is stale

main moved to 0.235.0 when PR #487 merged (closing #294). This branch also declares 0.235.0,
so Check version increment is currently green only because it ran against the older base. It will
flip to FAILURE as soon as this branch takes main.

Re-bump to the next free minor before merging. As of this comment 0.236.0 is claimed by the #490
lane (PR #492) and 0.237.0 by the #478 lane, so pick above those and re-read Cargo.toml from disk
rather than from a commit message.

Two traps worth naming, both measured in this repo:

  • git merge, never rebase. A rebase whose patch already merged prints
    dropping <sha> ... patch contents already upstream, exits 0 with zero conflicts, and silently takes
    the version-bump commit with it. The branch then sits AT main's version with no bump commit left to
    notice.
  • After any merge, re-read the version from the file on disk, not from the log, and scan the output
    for dropping as well as CONFLICT.

Posting rather than pushing, since another lane owns this branch.

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loop-reviewer — VERDICT: PASS

Audited head: 90311cf35e128b69cb7cb2e3a658f53379357a76 (resolved from the remote; PR is draft,
all checks SUCCESS, 0 unresolved threads). Scoped re-gate of the 20:54Z CHANGES-REQUIRED review from
11b3dd2e. Read-only in C:/tmp/worktrees/dn-verify; no shared checkout mutated, no commits.
gitnexus is ~300 commits stale for this repo (returns a false-safe impactedCount: 0), so blast
radius was done with grep + direct read.

1. Prior findings — fixed in code/text, not merely resolved

  • G1 (SPEC reported a capability the node lacks) — FIXED. SPEC.md:8335-8343 now reads
    "§25.10's verification of OTHER peers' claims is BUILT BUT INERT — no claim is verified on a
    running node today
    ", states bonded is unreachable, that the chain read is short-circuited
    before it is paid, that the ranking is a no-op on every slate, warns "A reader must not take this
    bullet as saying collateral is enforced; it is not", and names dig-node#473. §25.6a's normative
    clauses are untouched, as directed. This distinguishes satisfied from vacuously satisfied.
  • G2 (Closes #466) — NOT fixed at this head. See the blocking precondition below.
  • Round-2 fixes still present and load-bearing: VerdictKey.claiming_peer (bond_verify.rs:83,
    hashed at :95-101); the self-lifting short-circuit declaration_source_is_readable() at
    bond_verify.rs:167, applied at :306 (sync) and :484 (async) ahead of the chain read.

2. Delta 11b3dd2e..90311cf3 — behaviour-preserving

Three lane commits plus two merges of origin/main. The only lane-authored content commit is
32864a2e, and it touches exactly SPEC.md, crates/dig-node-core/src/mirror_bond.rs (module doc
only) and crates/dig-node-core/src/download.rs (test-only: renames
the_engine_ranks_a_disproven_bond_last......_promotes_a_proven_bond_without_sinking_a_disproven_one
and reorders the fixture slate so credit-only answers 9,8,7 while a three-tier lattice answers 9,7,8 —
the test now discriminates where it previously did not). No production behaviour change, so the
loop-security PASS from 11b3dd2e carries over. Everything else in the diffstat
(module_stream.rs, profile_sync.rs, capsule_serve_peak_memory.rs, control.rs, …) arrived via
the merges of #487/#488 from main.

3. Version — OK

Cargo.toml on disk is 0.236.0; origin/main is 0.235.0. Strictly greater.

4. Tests — real counts, not a filter matching nothing

  • cargo test -p dig-node-service --test mirror_bond_verify10 passed; 0 failed; 0 filtered out.
  • cargo test -p dig-node-core --lib mirror_bond8 passed; 0 failed; 1065 filtered out.

Both ran the expected number of tests.

BLOCKING MERGE PRECONDITION — the PR body still closes #466

The body line 1 says Refs #466 -- deliberately NOT a closing keyword, but the last line of the
body is Closes #466
, and GraphQL closingIssuesReferences.totalCount is 1. Merging as-is
closes #466 and records collateral enforcement as delivered when verdict_for returns Unverified
on every input — exactly the mis-statement G2 was raised about, and exactly what the loop-decider
verdict ("merge WITHOUT Closes #466") forbids.

This is a body edit, which this gate does not make and the orchestrator owns. Delete the trailing
Closes #466 and re-assert totalCount == 0 before merging.
The code verdict is PASS; this
precondition is not optional.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-reviewer — VERDICT: PASS

Audited head: 90311cf35e128b69cb7cb2e3a658f53379357a76 (resolved from the remote; PR is draft,
all checks SUCCESS, 0 unresolved threads). Scoped re-gate of the 20:54Z CHANGES-REQUIRED review from
11b3dd2e. Read-only in C:/tmp/worktrees/dn-verify; no shared checkout mutated, no commits.
gitnexus is ~300 commits stale for this repo (returns a false-safe impactedCount: 0), so blast
radius was done with grep + direct read.

1. Prior findings — fixed in code/text, not merely resolved

  • G1 (SPEC reported a capability the node lacks) — FIXED. SPEC.md:8335-8343 now reads
    "§25.10's verification of OTHER peers' claims is BUILT BUT INERT — no claim is verified on a
    running node today
    ", states bonded is unreachable, that the chain read is short-circuited
    before it is paid, that the ranking is a no-op on every slate, warns "A reader must not take this
    bullet as saying collateral is enforced; it is not", and names dig-node#473. §25.6a's normative
    clauses are untouched, as directed. This distinguishes satisfied from vacuously satisfied.
  • G2 (Closes #466) — NOT fixed at this head. See the blocking precondition below.
  • Round-2 fixes still present and load-bearing: VerdictKey.claiming_peer (bond_verify.rs:83,
    hashed at :95-101); the self-lifting short-circuit declaration_source_is_readable() at
    bond_verify.rs:167, applied at :306 (sync) and :484 (async) ahead of the chain read.

2. Delta 11b3dd2e..90311cf3 — behaviour-preserving

Three lane commits plus two merges of origin/main. The only lane-authored content commit is
32864a2e, and it touches exactly SPEC.md, crates/dig-node-core/src/mirror_bond.rs (module doc
only) and crates/dig-node-core/src/download.rs (test-only: renames
the_engine_ranks_a_disproven_bond_last......_promotes_a_proven_bond_without_sinking_a_disproven_one
and reorders the fixture slate so credit-only answers 9,8,7 while a three-tier lattice answers 9,7,8 —
the test now discriminates where it previously did not). No production behaviour change, so the
loop-security PASS from 11b3dd2e carries over. Everything else in the diffstat
(module_stream.rs, profile_sync.rs, capsule_serve_peak_memory.rs, control.rs, …) arrived via
the merges of #487/#488 from main.

3. Version — OK

Cargo.toml on disk is 0.236.0; origin/main is 0.235.0. Strictly greater.

4. Tests — real counts, not a filter matching nothing

  • cargo test -p dig-node-service --test mirror_bond_verify10 passed; 0 failed; 0 filtered out.
  • cargo test -p dig-node-core --lib mirror_bond8 passed; 0 failed; 1065 filtered out.

Both ran the expected number of tests.

BLOCKING MERGE PRECONDITION — the PR body still closes #466

The body line 1 says Refs #466 -- deliberately NOT a closing keyword, but the last line of the
body is Closes #466
, and GraphQL closingIssuesReferences.totalCount is 1. Merging as-is
closes #466 and records collateral enforcement as delivered when verdict_for returns Unverified
on every input — exactly the mis-statement G2 was raised about, and exactly what the loop-decider
verdict ("merge WITHOUT Closes #466") forbids.

This is a body edit, which this gate does not make and the orchestrator owns. Delete the trailing
Closes #466 and re-assert totalCount == 0 before merging.
The code verdict is PASS; this
precondition is not optional.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 2, 2026 01:54
@MichaelTaylor3d
MichaelTaylor3d merged commit 2098dbc into main Sep 2, 2026
16 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/mc-verify branch September 2, 2026 01:57
MichaelTaylor3d added a commit that referenced this pull request Sep 2, 2026
The bond verifier shipped in #467 was inert by construction:
`peer_declaration` returned `NotReadable` unconditionally, so `Bonded` was
unreachable, `verdict_for` short-circuited before any chain read, and every
holder got one verdict. That was the correct posture while nothing could bind
a coin to a claimant -- a coin proves that *a* bond exists and never that the
peer offering the record holds it, and promoting on the chain half alone
would rank a stranger republishing a public coin id first at zero collateral.

`dig-mirror-coin` 0.8.0 supplies the missing half. A coin's owner may declare
`dig-peer:<64-hex>` in the memo tail; only the owner's key can produce the
spend that writes it, so the term is an owner attestation carried by executed
on-chain code. `peer_declaration` now delegates to that crate's typed
accessor rather than parsing the tail here, because a second parser for a
security-critical format makes a divergence a silent authorization difference
instead of a compile error.

Promotion now requires BOTH bindings: coin -> content via
`MirrorCoin::advertises`, and coin -> peer id via the declaration.

`PeerDeclaration::NotReadable` is removed. It described a situation that no
longer exists, and a variant nothing constructs is a state the type claims to
model and does not.

The address-substitution residual, resolved
----------------------------------------------------------------
The declaration binds coin -> peer id, never peer id -> address, so a record
carrying an honest holder's peer id, that holder's real coin id and an
ATTACKER's addresses satisfies every check here and IS promoted.

SPEC 25.6a previously required closing that with an authoritative-record
restriction, on the stated grounds that "a dialler is not by itself a
backstop, because peer ids are derived from the presented certificate rather
than pinned against the dialled identity". That premise is false for every
path dig-node dials on: the download path makes the record's own
`provider_peer_id` the `PeerTarget` pin, dig-nat passes it to dig-tls, and
the verifier fails the handshake with `peer_id mismatch: expected .., got ..`.
dig-peer re-checks after connect, and fetched content is merkle-verified
against the caller's own requested root regardless. The attacker buys a
refused connection, not a redirected reader.

The restriction as written is also not implementable at this layer, and that
is worth recording rather than rediscovering: dig-dht really does keep
authoritative and hearsay records in two separate stores, but erases the
distinction in `merge_dedup_by_provider` before `find_providers` returns, and
a locator restricted to authoritative records would return almost nothing --
that store holds keys this node is k-closest to, not content it wants.

What this layer owes instead is a BOUND, and it is added here: at most one
record is promoted per claimed peer id, so one stolen identity cannot spend
the whole verified budget. A duplicate falls back to the baseline tier it
would have occupied with no verifier at all, never below it, so the lattice
stays credit-only.

Also corrects `peer.rs`'s note asserting no dial pins a peer id. The narrow
fact behind it -- dig-gossip's legacy rustls outbound does not pin, and every
`expected_peer_id` there is `#[cfg(test)]` -- is true; the generalisation to
every dial was not, and dig-node never dials on that path.

Closes #473
Closes #466

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit that referenced this pull request Sep 2, 2026
…to 0.249.0

Brings origin/main (0.245.0, including #467, #489, #492, #497) onto the
branch and sets the workspace version to the pre-assigned 0.249.0.

Conflicts and how they were resolved:

- `Cargo.toml` — a pure version collision (branch 0.242.0 vs main
  0.245.0). Every other main-side hunk was already applied by the
  auto-merge; the only difference from `origin/main` in this file is the
  version line, now 0.249.0.
- `Cargo.lock` — taken wholesale from `origin/main`, then re-locked with
  `cargo update -w`, which re-points the two workspace members whose
  manifests moved (`dig-node-service` 0.245.0 -> 0.249.0, `dig-wallet`
  0.47.0 -> 0.48.0). Nothing in the tree still reads 0.242.0.
- `crates/dig-wallet/src/sage/rpc.rs` — reported as a conflict by an
  earlier attempt; on this merge git resolved it textually because the
  two sides touch disjoint regions of the file. The result was read
  against BOTH parents rather than accepted on git's word:

  * MAIN's hunks are intact. `is_definitive_rejection` keeps the #497
    narrowing — a refusal frees inputs only when its stated reason is
    bundle-intrinsic (`super::chain::refusal_is_bundle_intrinsic`), with
    a HOLD default — and the #492 doc block stating that `synced` is a
    CURRENCY test computed independently of the routing tier, so
    `{source: "db", synced: false}` is a reachable state.
  * THE BRANCH's hunk is intact. `replica_answer_is_current` still
    delegates to `sync_supervisor::FollowingEvidence::measure`, which
    withholds the evidence when EITHER the replica or the peer height is
    unmeasured, so a `synced` phase cannot be emitted without the peak
    height that bounds it (#495).
  * No rival implementation survives the merge. The pre-#495
    `is_following` predicate is gone from the tree; `FollowingEvidence`
    is the single producer consumed by both the money reads
    (`rpc.rs:1085`) and the status endpoint
    (`sync_supervisor.rs:490`), which is what makes the
    `{phase: "synced", peak_height: null}` pairing unrepresentable
    rather than merely unlikely.

No behaviour was chosen over the other side: both guards are load-bearing
on different questions — one on whether a refusal may free inputs, the
other on whether a currency claim may be made at all.

dig-wallet: 772 passed, 0 failed, 1 ignored.
dig-node-service: 774 passed, 0 failed.

Note: `cargo test` on this Windows host needs RUST_MIN_STACK raised
(default hits a rustc STATUS_STACK_BUFFER_OVERRUN ICE while encoding
dig-node-service metadata) — an environment limit, not a code fault.

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit that referenced this pull request Sep 3, 2026
The bond verifier shipped in #467 was inert by construction:
`peer_declaration` returned `NotReadable` unconditionally, so `Bonded` was
unreachable, `verdict_for` short-circuited before any chain read, and every
holder got one verdict. That was the correct posture while nothing could bind
a coin to a claimant -- a coin proves that *a* bond exists and never that the
peer offering the record holds it, and promoting on the chain half alone
would rank a stranger republishing a public coin id first at zero collateral.

`dig-mirror-coin` 0.8.0 supplies the missing half. A coin's owner may declare
`dig-peer:<64-hex>` in the memo tail; only the owner's key can produce the
spend that writes it, so the term is an owner attestation carried by executed
on-chain code. `peer_declaration` now delegates to that crate's typed
accessor rather than parsing the tail here, because a second parser for a
security-critical format makes a divergence a silent authorization difference
instead of a compile error.

Promotion now requires BOTH bindings: coin -> content via
`MirrorCoin::advertises`, and coin -> peer id via the declaration.

`PeerDeclaration::NotReadable` is removed. It described a situation that no
longer exists, and a variant nothing constructs is a state the type claims to
model and does not.

The address-substitution residual, resolved
----------------------------------------------------------------
The declaration binds coin -> peer id, never peer id -> address, so a record
carrying an honest holder's peer id, that holder's real coin id and an
ATTACKER's addresses satisfies every check here and IS promoted.

SPEC 25.6a previously required closing that with an authoritative-record
restriction, on the stated grounds that "a dialler is not by itself a
backstop, because peer ids are derived from the presented certificate rather
than pinned against the dialled identity". That premise is false for every
path dig-node dials on: the download path makes the record's own
`provider_peer_id` the `PeerTarget` pin, dig-nat passes it to dig-tls, and
the verifier fails the handshake with `peer_id mismatch: expected .., got ..`.
dig-peer re-checks after connect, and fetched content is merkle-verified
against the caller's own requested root regardless. The attacker buys a
refused connection, not a redirected reader.

The restriction as written is also not implementable at this layer, and that
is worth recording rather than rediscovering: dig-dht really does keep
authoritative and hearsay records in two separate stores, but erases the
distinction in `merge_dedup_by_provider` before `find_providers` returns, and
a locator restricted to authoritative records would return almost nothing --
that store holds keys this node is k-closest to, not content it wants.

What this layer owes instead is a BOUND, and it is added here: at most one
record is promoted per claimed peer id, so one stolen identity cannot spend
the whole verified budget. A duplicate falls back to the baseline tier it
would have occupied with no verifier at all, never below it, so the lattice
stays credit-only.

Also corrects `peer.rs`'s note asserting no dial pins a peer id. The narrow
fact behind it -- dig-gossip's legacy rustls outbound does not pin, and every
`expected_peer_id` there is `#[cfg(test)]` -- is true; the generalisation to
every dial was not, and dig-node never dials on that path.

Closes #473
Closes #466

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant