feat(mirror): advertised URLs this node can be fetched at (#426) - #430
Conversation
Sequencing note — this PR is what makes dig-node#423's gating defect REACHABLERecorded here because it changes what has to land first, and neither PR can see the other. PR #423's security re-gate found The gate judged it unreachable in production, correctly for the head it read — because This PR is the one that fills that list. The moment it lands, an empty-URL refusal stops guarding So: #423's intra-pass reservation fix lands first. That is now in flight — the reservation gains Nothing here needs changing. The work on this PR is sound and its staging is honest — SPEC §25.10 When this lane resumes, the remaining half is the wiring into The shape decision on #426 stands as written: |
9b53dca to
5480330
Compare
Co-Authored-By: Claude <noreply@anthropic.com>
…426) A mirror coin publishes where its store can be fetched from, and dig_mirror_coin::create refuses an advertisement carrying no URL -- so a node with no public name cannot bond. The value is operator-set and never derived: a coin's URLs are fixed at create for the epoch, so a self-inferred address that is unreachable from outside, or that changes, stakes real $DIG on a claim the node cannot keep and SPEC.md 25 penalises. An unset value means this node advertises nothing and creates nothing, which is the correct default: publishing an unreachable URL is worse than publishing none. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
#426) The parser shipped without a consumer: `spawn_mirror_passes` handed `NodeMirrorEffects` an empty URL list, so every create refused by name and no mirror coin could ever be made. The scheduler now reads the operator's list once at bring-up through `advertise::configured_urls`, which also reports each rejected entry with the reason it was dropped, and passes it to every pass. An empty answer keeps its meaning: the create still refuses before any chain read and no spend is attempted, which is the money-safe default SPEC.md 25.10 requires. SPEC.md 25.10's PARTIALLY SATISFIED admonition is removed in the same commit, because the wiring is what made it false. Co-Authored-By: Claude <noreply@anthropic.com>
…mp to 0.176.0 The refusal probe asserted only that no bundle was broadcast, which `dig-mirror-coin`'s own empty-URL refusal satisfies identically — so a guard moved to after coin selection would have kept the test green while reserving a funding coin for a create that can never happen, starving the next bond in the same pass. The fixture chain now counts its address lookups, and the probe asserts zero: a create that never reads an address is the only observation that separates the two placements. Verified by mutation: neutering the guard leaves the outcome assertions green and fails this one. Co-Authored-By: Claude <noreply@anthropic.com>
…ling them `ordinary_dig_coins` seeds a grandparent with `[salt; 32]`, so a byte literal reads to CodeQL as a hard-coded cryptographic value — the same finding dig-node#917 and #950 already produced, and which the sibling reservation probe already avoids this way. Fixed at the root rather than dismissed: the salts are now derived from a domain string, so they stay deterministic and distinct per fixture while ceasing to be literals. Co-Authored-By: Claude <noreply@anthropic.com>
9955f86 to
b3e96fa
Compare
loop-security — IN PROGRESS, not the verdictAudited head: Posting as I go so nothing is lost to a stall. No verdict yet. Settled so far — the four reachability questions1. The intra-pass reservation is intact and load-bearing at this head. CONFIRMED.
No 2.
3. The refusal is placed BEFORE any coin selection or chain read. CONFIRMED, by placement and not only by outcome.
4. No memo injection is possible. CONFIRMED at the crate boundary.
Additionally, Single source of the value — CONFIRMED
Still open, being worked now
One process note, not a finding
|
loop-security — IN PROGRESS, not the verdict (2/2)Head Nothing here is CRITICAL or HIGH. One MEDIUM finding, established by an executed probe, is below. FINDING (MEDIUM) —
|
loop-security VERDICT: CHANGES-REQUIRED (one MEDIUM, doc-level; nothing CRITICAL or HIGH)Audited head: No CRITICAL and no HIGH finding. Zero attacker-reachable defects. I traced every producer of every value this diff introduces; none is reachable by a peer, an RPC caller, or anything on a network. The single gating item is a normative SPEC clause that is not true of the code shipped beside it, and it is resolvable either direction in a few lines. The load-bearing question — is the guard that now carries the weight actually carrying it?This PR removes the empty-list condition that made #423's double-spend unreachable. All four properties that must hold for that to be safe are CONFIRMED at this head, in the code rather than from the lane's claim:
Property 4 is also pinned by a test that can actually see it: Two rebases did not disturb any of this. Attack surface — what I checked and what would have made it fail
The one gating findingMEDIUM — SPEC.md 25.10 asserts a
|
…its spelling SPEC.md 25.10 requires that an entry whose host can only mean this machine is never published, but `classify` read that rule off the host's SPELLING, so two whole classes reached a mirror coin's memos: * An IPv4-mapped or IPv4-compatible IPv6 address under any scheme. `Ipv6Addr::is_loopback` is true only of `::1`; the meaning of `[::ffff:127.0.0.1]` lives in its low 32 bits and nothing looked there. * A bare IPv4 literal under a NON-SPECIAL scheme. `dig://` is an intended input, and a non-special scheme takes the WHATWG opaque-host path, so `127.0.0.1` arrived as `Host::Domain` and the arm holding the entire loopback rule never ran. The second is the dangerous one: an operator who sets `DIG_MIRROR_ADVERTISE_URLS=dig://127.0.0.1:4161/` gets no warning and an INFO line saying the node is advertising, while `create` locks real $DIG against an address every reader resolves to itself — unrecoverable within the epoch, because a coin's URLs are fixed at create. The v4 rule now has ONE home, `is_this_machine_only_v4`, which all three paths that can yield a v4 address funnel through, so no two can drift. The v6 arm asks its own predicates BEFORE unwrapping an embedded v4, because `::1` unwraps to the ordinary global `0.0.0.1`. A private or LAN address stays publishable, however it is written: this closes "can only mean this machine", not "is not public". Co-Authored-By: Claude <noreply@anthropic.com>
Gating finding closed: the this-machine rule is now on the address DENOTED, not on its spellingFixed in the code, at
Red/green, proving each fixture load-bearing. The two new fixtures were run against the After the fix, Every accepted-but-shouldn't-be case from your probe is a fixture, both classes, with the SPEC.md §25.10 widened to describe the code rather than the reverse —
Verified against Not touched, as instructed: PR stays DRAFT. Re-gate scope is this leg only. |
Closes #426. Child of https://github.com/DIG-Network/dig_ecosystem/issues/3166.
A mirror coin publishes, in its memos, the URLs its store can be fetched from, and
dig-mirror-coin::createrefuses an advertisement carrying none. This PR gives the nodethat value, and then makes it reach the coin.
What it does
Half one — the operator surface.
mirror/advertise.rsturns one operator-setDIG_MIRROR_ADVERTISE_URLSvalue into the advertisement's URL list. Entries are separatedby commas or whitespace, duplicates are dropped, and the operator's ORDER is published
verbatim: §5.2 recommends listing IPv6 first, but these are the operator's own fetch hints
and silently sorting them would misstate a preference the node does not hold.
The value is configured and never derived. The one address this machine could infer —
the STUN reflexive address — is exactly the wrong thing to publish: a coin's URLs are fixed
at create for the whole epoch, so an address that is unreachable from outside or that simply
changes leaves real $DIG staked on a claim SPEC.md §25 penalises.
An entry whose host can only mean this machine (loopback, unspecified, link-local,
localhost,dig.local) is dropped with a warning naming the reason; its publishablesiblings in the same list survive. A private/LAN address IS published — that is a deliberate
operator choice risking only their own stake.
Half two — the wiring.
spawn_mirror_passespreviously handedNodeMirrorEffectsanempty list, so every create refused by name and no mirror coin could ever be made. It now
reads the operator's list ONCE at bring-up through
advertise::configured_urls— once,because the value is a configuration rather than an observation, and re-reading it per pass
would let the list a warning was emitted about drift from the list actually published.
An empty answer keeps its meaning:
createstill refuses before any chain read, so nocoin is selected, nothing is reserved, and no spend is attempted. That is the money-safe
default and it is what a node with nothing honest to say does.
Blast radius checked
Wiring a previously-empty argument, so the radius is the create path and nothing else.
mirror::advertise— newconfigured_urls; no existing caller, nothing else reads themodule.
NodeMirrorEffects::new— signature unchanged; theadvertised_urlsfield alreadyexisted and was already threaded to
spends::build_create. Its three call sites areserver.rs:2789(changed) and two integration probes (unchanged).NodeMirrorEffects::create— the empty-list refusal atlifecycle.rs:381is unchanged andstill precedes coin selection.
This PR is what makes the dig-node create path reachable. The empty list was, until now,
the thing preventing any create at all, and a security gate on #423 weighed severity against
that. The two guards that matter are present and verified intact after the rebase:
production_broadcaster()as the single seam behind bothopen_signerandspawn_mirror_passes, and #423's intra-pass funding-coin reservation, whoseRefCellborrowstays scoped to the selection and is released before signing.
How it was verified
crates/dig-node-service/tests/mirror_advertised_urls.rs— a new integration target, so itis NOT compiled by
cargo test --lib. Both probes drive the REAL composition — environment →configured_urls→NodeMirrorEffects::create→ a signed bundle — over a genuine operatorwallet and genuine CAT funding coins, and read the answer off the broadcast bundle's own
solution bytes rather than off anything reported.
the_configured_urls_reach_the_coin_in_the_operators_order— a fixture mixing a loopbackentry between two publishable ones. Asserts both survivors appear in the coin, that the
loopback host does not, and that the operator's order is preserved. The fixture lists IPv6
SECOND on purpose: a fixture already in the recommended order could not distinguish "no
sort" from "sorts IPv6 first".
an_all_rejected_value_refuses_and_spends_nothing— funded deliberately, so a refusalcannot be a funding refusal in disguise. Asserts the create refuses, that nothing is
broadcast, and that zero chain addresses were read.
Mutation-verified, both load-bearing:
configured_urlsreturnsVec::new()advertised_urls.is_empty()guard neuteredThe second mutation is why the chain-read assertion exists. The first version of that probe
asserted only that no bundle was broadcast — which
dig-mirror-coin's own empty-URL refusalsatisfies identically, so the guard could be moved to AFTER coin selection and the test would
have stayed green while a funding coin was reserved for a create that can never happen,
starving the next bond in the same pass. The measured survival is recorded in the commit.
SPEC
SPEC.md§25.10'sPARTIALLY SATISFIEDadmonition said "nothing reads them yet". The wiringis what made that false, so it is removed in the same commit. Every remaining clause in the
section was re-checked against code in this diff: the configured-not-derived rule and the
memo publication (
advertise.rsmodule doc,lifecycle.rs:425), absolute-URL andthis-machine rejection (
advertise.rsclassify), order preserved (parse_advertised_urls),the warning per rejected entry (
configured_urls), and the refusal on an empty list(
lifecycle.rs:381).Version
0.175.1 → 0.176.0 — minor: a new operator-facing capability, no API removed or changed.