Skip to content

feat(mirror): derive the advertised mirror URL from this node's reflexive peer address - #562

Merged
MichaelTaylor3d merged 8 commits into
mainfrom
loop/3197-wu2-reflexive-mirror-url
Sep 5, 2026
Merged

feat(mirror): derive the advertised mirror URL from this node's reflexive peer address#562
MichaelTaylor3d merged 8 commits into
mainfrom
loop/3197-wu2-reflexive-mirror-url

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE — gate round in progress.

Epic: https://github.com/DIG-Network/dig_ecosystem/issues/3197 (WU2). Version 0.254.80, assigned
by the orchestrator (PR #561 has taken 0.254.79). Related: #566 (agreement between two independent
STUN sources) and https://github.com/DIG-Network/relay.dig.net/issues/11 /
https://github.com/DIG-Network/relay.dig.net/issues/12 (the relay half).

This PR deliberately closes no issue, and the empty closing-reference list is correct rather than a
dropped keyword.
The only ticket is that super-repo epic; it spans WU0-WU4, dig_ecosystem is
private while dig-node is public, and a submodule PR cannot close a superproject issue in any case.
Closing it from here would mark four unstarted work units done. It is referenced by full URL above,
which is what the no-orphans rule asks for.

What changed

The mirror advertisement was operator-configured or nothing. A node that had never had
DIG_MIRROR_ADVERTISE_URLS set advertised nothing, created no coin, and earned nothing — which is
every node out of the box. The node can learn one address about itself, the reflexive mapping of its
dig-peer socket, and this makes that the default.

  • advertise::effective_urls(operator, address) decides one pass, pure over its two inputs:
    an operator value overrides; otherwise the reflexive address in dig://host:port form, IPv6
    first, gated on liveness (relay_reserved || direct_mapping), never on a probe-back.
  • The URL names the dig-peer wire (:9444), not the HTTP content port. DIG_NODE_PORT (9778)
    is loopback-bound by default (control.rs:133, config.rs:157), so no relay mediates it and a
    coin naming it would advertise somewhere no stranger can reach. classify already accepts a
    non-special scheme on purpose.
  • Six named states, not one empty list, because the four that publish nothing have four
    different remedies. create now refuses with the state's own sentence instead of a fixed
    "none is configured".
  • An operator value that yields nothing does NOT fall back to the derived address. They named a
    place; staking their money elsewhere because of a typo is a surprise about money. Every
    already-configured node behaves exactly as it shipped.
  • The scheduler reads the operator half once at bring-up (configuration) and the derived half per
    pass
    (an observation — the address and the relay both change while the node runs), logging the
    state and any derived rejection on transition rather than every round.

The two guards on the value, and why they are not optional

relay.dig.net answers STUN, and for an IPv4 caller returns its load balancer's SNAT'd IPv6
address with a synthetic port rather than the caller's own (relay.dig.net#11; relay.dig.net#12 fixes
it with a same-family listener). Well-formed every time — correct magic cookie, matching transaction
id, a routable address that serves nothing. Nothing errors. dig-node prefers the relay tier, so
without a guard this change would write that into a coin, on chain, permanently, with collateral
locked behind it.

So a derived address faces two gates an operator's typed value does not:

  1. AGREEMENT. A reflexive reading carries its source, and only an address two different
    sources report
    is usable. No amount of checking one answer can see the defect above; a second
    source disagrees instantly. NC-12's shape — untrusted sources that must agree, never one that is
    trusted — applied to the node's reading of itself. Readings that disagree corroborate neither.
    This is dig-node#566's requirement, enforced at this boundary.
  2. ROUTABILITY. A derived private, CGNAT, link-local, loopback, documentation, benchmarking,
    discard-only, unique-local or reserved address is refused. An IPv6 address embedding an IPv4 one
    is judged by what it embeds. An operator's LAN address is still accepted, because that is a
    deliberate choice risking only their own stake — the asymmetry is the point and is asserted.

Neither the address FAMILY nor the owning RANGE is a rejection criterion, and both are now rules
rather than accidents.
relay.dig.net#11 is an address-family crossing: an IPv6 caller gets the
correct answer 3/3 from the same server. IPv6 is therefore both the working case and the
§5.2-preferred one, so a fix shaped as "distrust IPv6 from the relay" would discard good discovery
while keeping bad answers — an_ipv6_address_is_never_refused_for_being_ipv6 pins that, using an
address in the very range that surfaced the defect. Likewise blocking the AWS block would be wrong
for every node legitimately running on EC2, so an AWS address two sources agree on publishes like any
other, and that is a test.

The second source is NOT built here. It needs provenance plumbing through dig-node-core's peer
seam, which a sibling lane owns and which this lane was asked not to race. What is built is the
requirement, so an uncorroborated address cannot reach a coin while that lands. The adapter
accepts three shapes and only the attributed one can corroborate:

reflexive_addr shape read as
"1.2.3.4:9444" one reading, one unnamed source — never corroborated
["…", "…"] several readings, all the SAME unnamed source — never corroborated
[{"source": "…", "addr": "…"}] one reading per named source — can corroborate

Left to dig-node#566, stated rather than silently skipped

  • reported_port == the querying socket's own source port — the sharpest discriminator, and not
    available here: this layer receives an address someone reported, not the socket that asked. It also
    must not be lifted here even if it were, because a NAT'd node's reflexive port legitimately
    differs from its local source port — that is what NAT port mapping IS — so the comparison made
    anywhere but the STUN client would refuse exactly the population reflexive discovery serves.
    §25.10 now records that as a rule.
  • Family matches the transport queried over — same reason: which transport asked is the client's
    knowledge, not an attribute of a reported address.
  • Reject a family the node has no candidate address in — considered and rejected on measurement:
    candidate_addresses is the node's local interface set (seams/dig_peer/net.rs:261) and is
    empty for exactly the NAT'd nodes this targets, so the rule would refuse the whole target
    population.

Two doc corrections, in the same PR because the code contradicts them otherwise

  1. SPEC.md §25.10 said the URL "MUST NOT be derived". Rewritten: derived by default, operator
    override retained, liveness gate normative, corroboration and global-unicast normative, the
    no-special-casing rule normative, the six-state taxonomy tabulated, and the
    read-reachability-as-a-mapping trap named. §25.8's two references are brought into line (one
    named configured_urls, which no longer exists).

  2. mirror/advertise.rs overstated the penalty, saying a bad address "leaves real $DIG staked on
    a claim the node cannot keep, which SPEC.md §25 penalises". §25 is reward forfeiture, not
    slashing
    : dig-mirror-coin/SPEC.md §3 rules 4–5 and §5/§6.4 put reclaim behind the owner's key
    alone, require the full amount back, and give no path that reduces $DIG supply; the only penalty
    any code applies is credit denial (bond_verify.rs:393-499BondVerdict::Unverified, earns
    nothing). The true cost of a bad URL is that epoch's rewards, capital locked until reclaim, and
    a reclaim fee — never principal
    — the same cost as advertising nothing, which is why refusing
    to derive bought nothing. The module doc now says that, and so does the SPEC.

What a real host does today

reflexive_addr is hard-coded Value::Null at dig-node-core/src/lib.rs:4352 (dig_ecosystem#3198
owns the producer). Nothing here touches that — it is the deferred mapping-probe work. So the
decision on a real host is no_public_address, which creates nothing and says "this node does not
know its public address yet, so it is not advertising mirrors and not earning; nothing has reported
one"
— deliberately not "you have not configured this", because configuring cannot clear it.

direct_mapping likewise has no producer today and the field says so in its own doc, including
what must never be wired to it: reachability, which reports "direct" for the absence of a relay
and would make the liveness gate vacuously true for exactly the nodes it exists to stop.

How verified

cargo fmt --all -- --check, cargo clippy --workspace --all-targets --locked -- -D warnings, and
the mirror suites — commands, exit codes and test counts in a comment below, along with five
revert-proof batches
, each with its expected failing set written down before the run.

One of those runs is worth flagging: the operator-facing-line guard caught cargo fmt collapsing a
\ string continuation
into fourteen literal spaces mid-sentence. cargo fmt --check is satisfied
by that, since the formatter produced it, and a source grep misses it because the backslash is gone.
The repaired message uses concat! so no later formatter pass can repeat it, and the guard now walks
both operator-facing wrappers over every rejection rather than one.

Blast radius

NodeMirrorEffects::new's fourth parameter changes Vec<String>advertise::Effective (5 call
sites, all in this repo). configured_urlsconfigured_operator_urls, returning the parsed value;
no other crate calls either. PassContext::can_advertise keeps its type and meaning. Nothing outside
crates/dig-node-service/src/mirror/ and server.rs changes behaviour.

🤖 Generated with Claude Code

MichaelTaylor3d added a commit that referenced this pull request Sep 5, 2026
main advanced to 0.254.79 (#561 merged) while this branch was in flight,
and #562 holds 0.254.80 -- taking 0.254.81 per coordinator assignment
(only the coordinator can see every sibling's claim). Cargo.lock's
dig-node-service entry refreshed to match via `cargo update -p
dig-node-service --offline` (one line changed, no other dependency moved).

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

Copy link
Copy Markdown
Contributor Author

Local gate evidence — head 0b16bf7c

Run on the CI commands themselves, unpiped, exit codes read directly.

gate command result
fmt cargo fmt --all -- --check exit 0
clippy cargo clippy --workspace --all-targets --locked -- -D warnings exit 0, zero warnings
tests cargo test -p dig-node-service --lib --test mirror_advertised_urls --test mirror_intra_pass_reservation --test mirror_bond_verify --test mirror_operator_funding --test mirror_fee_ceiling --test mirror_bond_corroboration exit 0
unittests src\lib.rs                    824 passed; 0 failed
tests\mirror_advertised_urls.rs           4 passed; 0 failed
tests\mirror_bond_corroboration.rs       11 passed; 0 failed
tests\mirror_bond_verify.rs              11 passed; 0 failed
tests\mirror_fee_ceiling.rs               3 passed; 0 failed
tests\mirror_intra_pass_reservation.rs    2 passed; 0 failed
tests\mirror_operator_funding.rs         14 passed; 0 failed

869 tests actually ran, every binary reporting 0 filtered out. Stated because a cargo test
filter matching nothing exits 0 printing 0 passed; N filtered out, which reads as success.

Revert-proof — five batches, each prediction written down BEFORE the run

Every revert is a targeted single-line edit, git diff'd before running. No sed, no regex over
the source. The actual failing set matched the prediction exactly in all five: a revert breaking
more tests than predicted would mean the tests are coupled, and one breaking fewer would mean a
branch is unguarded.

A — derivation removed; the honest wording inverted

urls: derived.acceptedVec::new(); NoPublicAddress's sentence made to name the env var.

15 passed; 5 failed
  a_confirmed_direct_mapping_opens_the_gate_without_a_relay
  a_known_address_with_a_path_held_is_derived_and_advertised_ipv6_first
  a_this_machine_reflexive_address_is_never_derived_into_a_coin
  every_operator_facing_line_reads_as_a_sentence
  no_known_address_advertises_nothing_and_blames_the_address_not_the_operator
the reason must not send the operator to a setting that cannot clear it:
"this node does not know its public address yet, so it is not advertising mirrors
 and not earning; set DIG_MIRROR_ADVERTISE_URLS"

B — liveness gate off, override arm off, no-fallback arm off, IPv6-first inverted

15 passed; 5 failed
  a_known_address_with_a_path_held_is_derived_and_advertised_ipv6_first
  a_known_address_with_no_path_held_advertises_nothing
  a_reachability_of_direct_is_not_read_as_a_confirmed_mapping
  an_operator_value_beats_a_derivable_address
  an_unpublishable_operator_value_does_not_fall_back_to_the_derived_address

C — direct-mapping term dropped, classify skipped on derived entries, scheme http

17 passed; 3 failed
  a_confirmed_direct_mapping_opens_the_gate_without_a_relay
  a_known_address_with_a_path_held_is_derived_and_advertised_ipv6_first
  a_this_machine_reflexive_address_is_never_derived_into_a_coin

The middle one makes the port question checkable: under http the derived entry names the HTTP
content port, which is loopback-bound by default and which no relay mediates.

D — the integration branches, asserted against the broadcast bundle

create's refusal reason replaced by a fixed "none is configured"; self.advertised.urls replaced
by a placeholder; declared_peer: Some(..)None.

1 passed; 3 failed
  no_public_address_creates_nothing_and_does_not_blame_the_operators_configuration
  the_configured_urls_reach_the_coin_in_the_operators_order
  the_derived_address_reaches_the_coin_beside_the_peer_declaration

an_all_rejected_value_refuses_and_spends_nothing passing through all three is the control: its
refusal is decided by a guard none of the three reverts touched.

E — the two guards added for relay.dig.net#11

Corroboration reduced to other.addr == reading.addr (the source check dropped);
if !is_globally_routable(*addr)if false.

20 passed; 5 failed
  a_derived_address_outside_global_unicast_is_refused
  an_operator_may_publish_a_lan_address_the_derived_path_refuses
  one_source_is_never_enough_however_confident_it_is
  the_network_info_adapter_reads_the_address_the_provenance_and_the_relay
  two_sources_that_disagree_corroborate_neither

Note the third and fifth together: dropping the source check makes ten readings from ONE reporter
corroborate, and makes two disagreeing reporters each corroborate themselves. Both are the
relay.dig.net#11 shape, and both are caught.

Branch coverage

branch proved failing by
derived and advertised A, C, D
no address, so nothing A (wording), D (composed refusal)
override wins B, D
declaration preserved D (declared_peer: None)
one source is not enough E
routability E

The formatter defect the guard caught, for the record

Mid-work, cargo fmt collapsed a \ string continuation in the new rejection message and kept the
indentation as literal spaces:

Rejection::NotGloballyRoutable: a run of two spaces is an eaten line continuation, not prose:
"... which is a broken reading of              where it sits rather than a place to stake an epoch on"

cargo fmt --check was satisfied — the formatter produced it — and a source grep could not see it
because the backslash was already gone. Only the guard, which asserts on the composed runtime VALUE,
could. Two follow-ups: the message now uses concat! so no later formatter pass can repeat it, and
the composed create refusal in the integration suite gained the same runtime assertion.

What was NOT chased

The two build .deb legs fail on Failed to fetch … libexpat1_2.2.10-2+deb11u7_arm64.deb 404
bullseye's security pool rotating a package out from under a stale apt index. Repo-wide, hits #561
identically, owned by another lane, not a required check. Nothing in this diff touches packaging.

reflexive_addr remains hard-coded Value::Null at dig-node-core/src/lib.rs:4352. That is
dig_ecosystem#3198's deferred mapping-probe work and is deliberately untouched, which is why every
fixture supplies a synthetic address and why the shipped decision on a real host today is
no_public_address.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Re-verified on f1595f1c, after merging main

Main moved to 0.254.79 when #561 landed, which put this branch DIRTY on the workspace version line.
Merged with merge-main-keep-version.sh; version re-read from the FILES on disk, not from the
commit log
Cargo.toml:35 and Cargo.lock:3041 both say 0.254.80, and the branch is 0 commits
behind main.

Whole gate re-run on the merged head:

gate result
cargo fmt --all -- --check exit 0
cargo clippy --workspace --all-targets --locked -- -D warnings exit 0, zero warnings
lib + 4 mirror integration suites exit 0 — 824 + 4 + 11 + 2 + 14 = 855 passed, 0 failed, every binary 0 filtered out

One thing worth re-measuring rather than assuming, now that #561 has merged: reflexive_addr is
STILL hard-coded Value::Null at dig-node-core/src/lib.rs:4352. #561 added the public-STUN
fallback inside the peer seam but did not surface it on dig.getNetworkInfo, so a real host today
still reads no address and this decision is no_public_address — creating nothing, and saying so in
terms an operator can act on rather than blaming their configuration.

That also means the corroboration gate is not yet exercised by a live producer. When one arrives it
must attribute each reading to its source, or the answer stays uncorroborated_address — which is
the deliberate failure direction, given that the one source answering STUN today returns its load
balancer's address ten times out of ten (relay.dig.net#11).

@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/3197-wu2-reflexive-mirror-url branch from f1595f1 to 1ca5a65 Compare September 5, 2026 06:43
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Corrected against the re-measurement of relay.dig.net#11 — and what this layer checks vs leaves to dig-node#566

The first framing of that defect was "the relay reports its load balancer's address, not the
caller's"
. Re-measured from two external vantage points, it is narrower: an IPv6 caller gets the
correct answer 3/3, and only an IPv4 caller gets the balancer's address
, because
preserve_client_ip is mandatory for UDP ip-target groups and AWS documents it as having no effect
on traffic converted from IPv6 to IPv4. relay.dig.net#12 fixes it with a same-family listener.

The guard here needed no change, which is the useful part of the result. Corroboration compares
addresses and never families; the routability gate excludes ranges by what they DENOTE, never by who
owns them. Concretely, already in this PR before the correction:

  • a_provider_range_is_not_special_cased_when_two_sources_agree_on_it — an address in the AWS block
    that surfaced the defect publishes normally once two sources agree on it.
  • a_derived_address_outside_global_unicast_is_refused walks 14 addresses across both families; the
    IPv6 entries refused are 2001:db8::/32, fc00::/7 and fe80::/10 — documentation, unique-local
    and link-local — never IPv6 as such.

What the correction did change is the documentation, which stated the defect too strongly in a
normative voice, and that is worth more than it sounds: the first reading invites exactly the wrong
fix.
IPv6 is both the working case here and the §5.2-preferred one, so a rule shaped as "distrust
IPv6 from the relay" would discard good discovery while keeping the bad answers. §25.10 and the
module doc now say the family MUST NOT be a rejection criterion, and a new test
(an_ipv6_address_is_never_refused_for_being_ipv6) pins it using an address in the very range that
surfaced the defect — so a future well-intentioned fix fails here rather than shipping.

Implemented in this PR

check where
Two DIFFERENT sources must report the same address before it can be published PublicAddress::corroborated_addresses; state uncorroborated_address when only one has
Global unicast only — loopback, link-local, private, CGNAT, documentation, benchmarking, discard-only, unique-local, reserved all refused; an embedded IPv4 judged by what it embeds is_globally_routable
Family is never a rejection criterion; ordering only (IPv6 first, §5.2) derived_urls
No provider range special-cased, in either direction asserted, not merely intended
The operator's LAN address stays publishable while a derived one does not an_operator_may_publish_a_lan_address_the_derived_path_refuses

Left to dig-node#566, and why — not a shrug

  • reported_port == the querying socket's own source port. Agreed that this is the sharpest
    discriminator, and it is not implementable here: this layer receives an address someone reported,
    not the socket that asked. More importantly it must not be lifted here even if it were
    available — a NAT'd node's reflexive port legitimately differs from its local source port, since
    that is what NAT port mapping IS, so the comparison made anywhere but the STUN client would refuse
    exactly the population reflexive discovery exists for. It is a real signal per-query at the client,
    and a trap outside it. Recorded as a rule in §25.10 rather than left for someone to rediscover.
  • Family matches the transport queried over. Same reason: "which transport asked" is the
    client's knowledge, not an attribute of a reported address.

I also considered and rejected a third check on the earlier framing — refusing a family the node has
no candidate address in. dig.getNetworkInfo's candidate_addresses is the node's local
interface
set (seams/dig_peer/net.rs:261) and is empty for exactly the NAT'd nodes this feature
targets, so that rule would refuse the whole target population.

The failure direction today

reflexive_addr is still hard-coded Value::Null (dig-node-core/src/lib.rs:4352) even after #561
merged — re-measured, not assumed. So a real host reads no address, the decision is
no_public_address, and nothing is written. When a producer arrives it must attribute each reading
to its source or the answer stays uncorroborated_address; a single source's word never reaches a
coin. Given that the one source answering STUN today is wrong for IPv4 callers, that is the direction
worth failing in.

Gate re-run on 306f7726

cargo fmt --all -- --check exit 0 · cargo clippy --workspace --all-targets --locked -- -D warnings
exit 0, zero warnings · lib + 4 mirror suites exit 0 — 825 + 4 + 11 + 2 + 14 = 856 passed, 0
failed
, every binary 0 filtered out.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

CI green on 306f7726 — still DRAFT, pending the gate round

check-merge-preconditions.sh --allow-draft, run unpiped with the exit code read directly:

tip 306f77266f324badcef6e409fba4ec8e846d57ec  base main
draft=true  mergeStateStatus=UNSTABLE  unresolvedReviewThreads=0
--- required status checks (from branch protection, asserted BY NAME) ---
  Lint commit messages               SUCCESS
  Check version increment            SUCCESS
  Rustfmt                            SUCCESS
  Clippy                             SUCCESS
  Test + coverage                    SUCCESS
  Release-script tests               SUCCESS
---
  UNSTABLE (non-required, does not gate): build .deb (linux-amd64)
RESULT: preconditions MET  [EXIT 0]

That last line settles the build .deb question from branch protection rather than from opinion: it
is not a required check, so the repo-wide apt-index failure another lane owns does not gate this.
build .deb (linux-arm64) went SUCCESS on this same head, which fits the stale-mirror diagnosis
rather than anything in this diff.

This stays a draft until the gate verdicts return. Undrafting is what signals the §2.4a gates are
satisfied, and green checks plus zero threads is exactly the state a well-behaved sibling squash-
merges — so it must not be set while a gate is still out. Gate tier for this diff: reviewer +
loop-security, since it decides what a coin claims and what money it locks.

MichaelTaylor3d added a commit that referenced this pull request Sep 5, 2026
…404s (#564)

* ci: WIP -- harden the .deb job's apt bootstrap against bullseye mirror-skew 404s

Placeholder commit to open the PR early; the real fix follows.

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

* ci(fix): retry the whole apt bootstrap so a bullseye mirror/pool 404 self-heals

The Native install packages workflow's deb job (package.yml, container:
debian:11) fails ~8s in on "Get:N ... 404 Not Found" for a DIFFERENT package
each run (libexpat1 arm64 in one run, libperl5.32 amd64 in the same run) --
run 33948322114. apt-get update already runs immediately before apt-get
install in the same step, ruling out Docker-layer caching. bullseye-security's
pool prunes superseded .deb files as point releases land, so the Packages
index served by one mirror/edge can briefly disagree with the pool state a
different edge serves -- a live probe just returned 200 on both files that
404'd in CI, confirming this is transient skew, not a removed package (and
archive.debian.org has no bullseye-security yet, so this is not an EOL/
archival cutover either).

Retry the WHOLE update+install sequence as a unit (5 attempts, linear
backoff) in every place this exact bootstrap step exists: package.yml's deb
job, build-binaries.yml's build-linux job (the actual release-binary
builder, same container, same two-line bootstrap -- a local composite
action can't run before actions/checkout, so this can't be hoisted into one
shared step), and setup-linux-build/action.yml's own toolchain install
(same container, same failure class, invoked by both callers). Each retry
gets an independent shot at an index+pool pair that agree. Add
-o Acquire::Retries=3 --fix-missing as belt-and-braces for genuine
transient network blips.

Base image kept at debian:11 (bullseye, glibc 2.31): the glibc floor is a
deliberate, explicitly-gated pin (dig_ecosystem#1736/#1741) tied 1:1 to this
container's own glibc, verified by ldd --version in setup-linux-build. Since
bullseye is not actually EOL/archived, there is no forcing reason to raise
it, and doing so silently would regress the very floor it exists to hold.

CI-infra only -- no Rust source touched, no version bump.

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

* fix(ci): drop --fix-missing -- it poisons the retry loop, not just masks a dep

Observed on a real run of the previous commit (dig-node CI 33949346891, job
101261055756, build .deb linux-amd64): attempt 1 hit the 404 on
libperl5.32, and --fix-missing let apt UNPACK ca-certificates/curl/git
anyway and attempt to configure perl/git/liberror-perl despite the missing
dependency -- leaving them half-configured (dpkg: "dependency problems
prevent configuration of git: git depends on perl; however: Package perl
is not configured yet"). Every later attempt then reported
"git is already the newest version" and never touched the network for
libperl5.32 again, so attempts 2-5 failed on dependency resolution alone,
not on a fresh fetch. The retry loop never got a second real shot.

Without --fix-missing, a failed fetch aborts the whole apt-get install
before unpacking anything (this is exactly what the ORIGINAL failing run,
33948322114, shows: "Unable to fetch some archives" with zero "Setting up"
lines) -- so every retry attempt starts from a genuinely clean container
state. -o Acquire::Retries=3 is unaffected by this and stays: it only
retries the same HTTP fetch at the transport level and cannot mask a
missing package the way --fix-missing does.

Corroborating evidence from that same run: the linux-arm64 leg succeeded
on attempt 1, cleanly installing libexpat1 -- the exact package that 404'd
in the original failing run -- confirming the underlying mirror/pool state
is transient and does self-heal.

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

* chore: bump version to 0.254.81

main advanced to 0.254.79 (#561 merged) while this branch was in flight,
and #562 holds 0.254.80 -- taking 0.254.81 per coordinator assignment
(only the coordinator can see every sibling's claim). Cargo.lock's
dig-node-service entry refreshed to match via `cargo update -p
dig-node-service --offline` (one line changed, no other dependency moved).

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

* fix(ci): reset both apt-list-cache and dpkg state before every retry

Defense in depth on top of dropping --fix-missing (previous commit), per
review: apt-get update can serve a cached index via a conditional GET
(If-Modified-Since/ETag) instead of genuinely re-fetching, so a retry
could in principle keep reading the same not-yet-caught-up index --
rm -rf /var/lib/apt/lists/* forces an unconditional refetch on the next
attempt. dpkg --configure -a + apt-get -f install clean up any
half-configured package regardless of how it got that way, rather than
relying solely on --fix-missing staying absent to prevent one.

Also corrected the comment to precisely attribute the half-configured-dpkg
evidence (CI run 33949346891) to commit 2e75679 -- the version that still
carried --fix-missing, one commit before its removal -- rather than
implying it was observed on the fixed code.

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

* fix(ci): point debian-security at security.debian.org -- the actual root cause

The retry loop and state reset (previous two commits) were never
load-bearing for this failure. Root cause, verified directly:
deb.debian.org's debian-security POOL is missing objects for this suite
that the canonical security.debian.org serves -- same path, same filename,
different host:

  404  http://deb.debian.org/debian-security/pool/updates/main/p/perl/libperl5.32_5.32.1-4+deb11u5_amd64.deb
  200  http://security.debian.org/debian-security/pool/updates/main/p/perl/libperl5.32_5.32.1-4+deb11u5_amd64.deb

The index was never stale -- checked directly:

  Last-Modified: Mon, 31 Aug 2026 19:41:45 GMT   Age: 67   X-Cache: HIT
  Package: libperl5.32   Version: 5.32.1-4+deb11u5

A correctly-current index named this exact file, on a host whose pool does
not have it. u5, u6 and u7 all 404 the same way on deb.debian.org -- a
persistent gap, not a point-in-time rotation, so no amount of retrying or
index-refreshing could ever have fixed it (five independent, verified-fresh
re-fetches all returned the identical 404 in the prior commit's run).

Fix: sed the security line in /etc/apt/sources.list to security.debian.org
before any apt-get call, in all three locations carrying this bootstrap.
Verified locally end-to-end (docker run debian:11): without the sed,
`apt-get install ca-certificates curl git` 404s on libperl5.32 every time;
with it, every package installs and configures cleanly.

The retry loop + state reset stay -- cheap and correct for genuine
transients -- but are demoted from "the fix" to defense-in-depth. Comments
corrected accordingly.

NOT addressed here, and deliberately not papered over: bullseye-security's
Release file states Valid-Until: Mon, 07 Sep 2026 21:13:04 UTC (two days
out). Bullseye is oldoldstable; once that date passes apt rejects the
index outright regardless of host. This is the glibc-floor base-image
decision from the family's original brief, now with a concrete deadline,
and is reported to the coordinator as its own decision rather than
resolved in this PR.

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

* fix(ci): force cache-busting refetch on the retry -- a second, different cause

The security.debian.org host fix (previous commit) is correct and confirmed
working: libperl5.32 now fetches successfully where it 404'd on
deb.debian.org every time. That was CAUSE 1 (permanent, per-host pool gap),
measured repeatedly: libperl5.32 u5 is 6/6 on security.debian.org, 0/6 on
deb.debian.org.

CAUSE 2 is different: security.debian.org itself can still 404 on an
object that demonstrably exists (libexpat1 u7 -- u8 does not exist, u7 is
current), because a specific Fastly POP is serving a stale or negative
cache entry for it. Measured: libexpat1 u7 is 20/20 from one vantage while
CI 404'd the identical URL from POP 151.101.130.132.

Add -o Acquire::http::No-Cache=true to the install (alongside the existing
Acquire::Retries=3) so a retry forces revalidation instead of being served
the same cached response -- rm -rf /var/lib/apt/lists/* already forces a
fresh INDEX; this forces fresh PACKAGE fetches, the layer actually
failing. Lengthen the backoff from attempt*5 to attempt*10: the previous
5-attempt run completed in about 25 seconds, not long enough for an edge
cache to have a real chance to invalidate.

Do NOT add --fix-missing (unchanged reasoning, confirmed correct again).

Comments rewritten to describe BOTH causes precisely, with their
measurements, replacing the earlier (wrong) "pool prunes superseded files"
framing that matched neither.

Not touched: bullseye-security's Release Valid-Until (2026-09-07) --
tracked as dig-node#565, the coordinator's call, not this PR's.

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

* fix(ci): pin the .deb job's apt bootstrap to a consistent debian snapshot

The real root cause was neither a stale index nor an edge-cache miss: bullseye's
live mirror network (both deb.debian.org and security.debian.org) is internally
inconsistent as the suite retires -- each host's Packages index names versions
its own pool no longer serves, and the mismatch differs per architecture
(openssl 1.1.1w-0+deb11u8 404s on arm64 while the identical version installs
fine on amd64). No mirror choice and no retry strategy can fix an index that
disagrees with its own pool.

Fix: stop depending on the live mirror network. Pin all three suite lines
(bullseye, bullseye-updates, bullseye-security) to the same immutable
snapshot.debian.org timestamp (20260901T000000Z), whose index and pool were
captured together and verified end-to-end -- every package that 404'd against
the live mirrors returns 200 here, on both archives. Check-Valid-Until is
disabled because a pinned snapshot's Release file will always age past its
Valid-Until; that is not the live-suite EOL signal dig-node#565 is about.

Also corrects this step's comments, which blamed a Fastly edge-cache miss and a
deb.debian.org-specific pool gap -- both real symptoms along the way, but
neither the underlying cause.

Confirmed locally in a fresh debian:11 container (amd64): apt-get install
fetches 22 packages / 17.9 MB from snapshot.debian.org and configures cleanly
on the first attempt, including openssl 1.1.1w-0+deb11u8, which 404'd against
every live mirror tried previously.

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

* fix(ci): apply the same snapshot pin to the two other duplicate bootstraps

build-binaries.yml's build-linux job and setup-linux-build/action.yml's shared
toolchain install carried byte-identical copies of the same stale
security.debian.org-only fix and its now-corrected comment (both matched the
pre-fix package.yml block by hash). Left alone, both would keep failing the
same way as bullseye-security retires -- build-binaries.yml in particular is
the job that produces the actual shipped release binaries, so it is higher
stakes than the .deb packaging job this PR started from.

Same fix in both places: pin all three suite lines to the verified
snapshot.debian.org 20260901T000000Z snapshot instead of the live mirror
network, with Check-Valid-Until disabled for the same pinned-snapshot reason
documented in package.yml. Comments are shortened to point at package.yml's
full write-up rather than tripling the same essay.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/3197-wu2-reflexive-mirror-url branch from 306f772 to 792f4ea Compare September 5, 2026 10:17
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

IN PROGRESS -- not the verdict. Detailed findings below; final PASS/CHANGES-REQUIRED verdict follows in a separate comment.

loop-security audit findings, head 792f4ea

Read the diff via gh pr diff 562, cross-checked byte-identical against a direct git fetch of
pull/562/head (no checkout, read-only) landing on the exact same SHA. Cross-checked claims against
origin/main (104353d) for dig-node-core, and against DIG-Network/dig-mirror-coin's SPEC.md (e89fdd39)
for the Section 25 doc rewrite.

  1. Both guards bind before the write -- confirmed, no bypass. effective_urls() (advertise.rs:639-689)
    only reaches AdvertiseState::Derived (the one state besides Override that populates urls) after
    PublicAddress::corroborated_addresses() is non-empty (agreement, advertise.rs:415-427) AND
    derived_urls() passes both is_globally_routable and classify (advertise.rs:705-728) AND
    address.is_live(). NodeMirrorEffects::create (lifecycle.rs:479) refuses before any chain read when
    not self.advertised.can_advertise(). Traced the ONLY call site of dig_mirror_coin::create in the
    whole repo -- mirror/spends.rs:180, reached solely through lifecycle.rs's gated create() -- so there
    is no alternate/RPC path that can inject a URL or a spend bypassing this gate (mirror/signer.rs's
    own doc: MirrorSpends has no public constructor other than wrapping
    dig_mirror_coin::create/::reclaim).

    The second-source-not-built-yet question, which is the core of this audit: confirmed NOTHING
    silently degrades to one-source trust. reflexive_addr is hard-coded Value::Null in
    dig-node-core/src/lib.rs:4352 at the true current origin/main tip (re-verified directly, not
    inherited from the PR's own claim) -- so PublicAddress::from_network_info reads an empty reflexive
    vec on every real host today, corroborated_addresses() is empty, and effective_urls returns
    NoPublicAddress with an empty urls list. When a future producer supplies exactly ONE named source,
    the code returns Uncorroborated (advertise.rs:660-666), still empty -- proven by
    one_source_is_never_enough_however_confident_it_is and by the adapter test's single-string-shape
    case. No code path treats one source as sufficient.

  2. Operator fallback -- confirmed refused. effective_urls's arm order (advertise.rs:640-652): an
    operator value that parses to nothing publishable is detected via non-empty operator.rejected --
    Off, returned BEFORE the derived branch is ever reached. A genuinely unset or whitespace-only env
    var produces an empty rejected list too (parse_advertised_urls, advertise.rs:747-751, blank
    split-fields are skipped rather than pushed to rejected), which is the correct discriminator between
    nothing-configured (fall through to derive) and something-configured-all-bad (refuse, no fallback).
    Test an_unpublishable_operator_value_does_not_fall_back_to_the_derived_address exercises this with a
    LIVE derivable address present, so the refusal is proven rather than incidental.

  3. Port and scheme -- confirmed. DERIVED_SCHEME is "dig", never http; derived_urls formats the URL
    from the reflexive SocketAddr directly, never from DIG_NODE_PORT. Test asserts the scheme AND port
    together (the_derived_address_reaches_the_coin_beside_the_peer_declaration). Residual note, not a
    defect in this PR: the actual port value depends entirely on the future dig_ecosystem issue 3198
    producer reporting the mapping of the dig-peer socket rather than some other socket. This code has
    no way to verify that from its own side -- it trusts whatever reflexive_addr says. Since that
    producer does not exist yet (confirmed null above), this is not exploitable today; flag for the
    3198 review rather than gating here.

  4. Liveness gate -- confirmed not over-permissive. is_live is relay_reserved OR direct_mapping
    (advertise.rs:384). direct_mapping is unconditionally false in from_network_info (advertise.rs:477,
    comment states nothing produces true yet) -- confirmed nothing wires dig.getNetworkInfo's
    reachability field into it, and a dedicated test
    (a_reachability_of_direct_is_not_read_as_a_confirmed_mapping) proves reachability equal to direct
    does NOT set direct_mapping to true (this is exactly the vacuous-gate trap the audit brief warned
    about, and it is explicitly guarded against). relay_reserved is read fresh every pass from
    PeerStatus::snapshot_json (dig-node-core/src/lib.rs:4332-4338, reflects the live pool relay state),
    and server.rs's scheduler now reads the derived value PER PASS rather than once at bring-up (diff
    server.rs:1494-1516) -- so the gate cannot go stale across the life of the process the way a
    captured-once value could.

  5. The two deferred checks, port-equality and family-matches-transport -- independently agree with the
    lane's reasoning. A NAT's external reflexive port legitimately differs from the internal source port
    precisely because that is what port-mapping NAT does; applying reported-port-equals-source-port at
    this layer (which never sees the source port at all -- only an address someone reported) would
    reject the exact NAT'd population this feature exists to serve, not just an edge case of it. Correct
    to leave that check in the STUN client (per-query, where the source port is actually known) rather
    than here. Deferral is sound engineering judgment, not a weakened gate.

  6. Non-criteria confirmed genuinely non-criteria. is_globally_routable partitions v6 and v4 only for
    ORDERING (advertise.rs:706-710), never for exclusion by family; no AWS or provider range appears
    anywhere in is_globally_routable_v4 or _v6. Tests an_ipv6_address_is_never_refused_for_being_ipv6
    and a_provider_range_is_not_special_cased_when_two_sources_agree_on_it both use the SAME
    2600:1f18:11a9::1 prefix that relay.dig.net issue 11 surfaced, confirming the fix targets agreement,
    not family or ownership.

  7. String integrity -- confirmed no corruption at head. Extracted advertise.rs and lifecycle.rs at the
    fetched head and manually inspected every backslash-continued string literal plus the concat! macro
    replacement for Rejection::NotGloballyRoutable (advertise.rs:657-660, single trailing space before
    "broken", no leading space after -- composes correctly). None contain a double-space defect. Two
    independent regression tests guard against recurrence: the module-internal walk asserting no line
    contains a double space over every Rejection and AdvertiseState sentence (advertise.rs:880-884), and
    the integration test asserting the same over lifecycle.rs's runtime-composed refusal string
    (mirror_advertised_urls.rs).

  8. SPEC.md Section 25 rewrite -- confirmed accurate against dig-mirror-coin's actual mechanism. Checked
    DIG-Network/dig-mirror-coin's SPEC.md directly (rules 4 and 5, Section 6.4): reclaim is explicit,
    owner-key-authenticated only, recreates the FULL locked amount, no path reduces DIG supply, and any
    XCH fee is drawn from separately supplied coins rather than the collateral. bond_verify.rs confirms
    BondVerdict::Unverified (credit denial, not fund loss) is the only penalty dig-node's own code
    applies. The doc rewrite from penalised-losing-a-stake to forfeiture-never-principal is a
    correction, not an overstatement or understatement -- it now matches both crates' real mechanisms.

Rebase spot-check: re-fetched the PR ref directly (git fetch origin pull/562/head, landing on
792f4ea, matches headRefOid exactly) and read the security-relevant
files from that exact commit object, not from a locally-cached branch -- so this is not inherited trust
in the tweaker's report. All guards above are present and intact post-rebase.

Process note, not a security finding: gh pr view currently reports mergeStateStatus DIRTY and
mergeable CONFLICTING. Traced the cause: origin/main advanced by one more version bump
(0.254.80 to 0.254.81, Cargo.toml and Cargo.lock only) after this branch's merge-base, so this is a
same-line version-string collision (this branch already correctly bumped past it, to 0.254.82) -- the
ordinary N-lanes-one-version-line problem, not a content conflict. Will need one more rebase before it
can merge; the code reviewed above is unaffected by that rebase, since only Cargo.toml and Cargo.lock
lines are in play.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

VERDICT: PASS

Audited head SHA: 792f4ea (resolved myself via
gh pr view 562 --json headRefOid and independently via git fetch origin pull/562/head, both agree).
This SHA is the current head; no push has landed since.

Summary (full evidence in the comment above, "IN PROGRESS" findings):

  • Both the agreement (corroboration) gate and the routability gate sit strictly between a derived
    address and the on-chain write; there is exactly one call site of dig_mirror_coin::create in the
    repo and it is reached only through the gated NodeMirrorEffects::create path. No bypass found.
  • The core question this audit was dispatched to answer -- whether the missing second source
    currently degrades to trusting one source -- is answered NO. reflexive_addr is hard-coded
    Value::Null at the true origin/main tip today, so the derived path yields NoPublicAddress (empty
    urls) on every real host; and even a future single-source producer would land on Uncorroborated
    (still empty), proven by a dedicated test. Nothing in this PR silently trusts one reading.
  • Operator-value-yields-nothing does not fall back to the derived address (tested with a live
    derivable address present, so the refusal is proven rather than incidental).
  • The derived URL names the dig-peer wire (scheme "dig"), never DIG_NODE_PORT/http.
  • The liveness gate cannot be satisfied by the vacuous "reachability: direct" reading; a dedicated
    test guards exactly that trap. relay_reserved is read live, per pass, from the peer pool.
  • The two declined checks (port-equality, family-matches-transport) are independently sound: a NAT's
    reflexive port is expected to differ from the local source port, so applying that check at this
    layer would reject the population reflexive discovery exists to serve. Correctly left to the STUN
    client.
  • No address-family or provider-range special-casing exists anywhere in the routability gate;
    IPv6 and an AWS-range address both pass once corroborated, matching the guard's stated rationale.
  • The SPEC.md section 25 rewrite (slashing -> forfeiture) is accurate: verified directly against
    DIG-Network/dig-mirror-coin's own SPEC.md (rules 4/5, section 6.4) and against bond_verify.rs's
    BondVerdict::Unverified mechanism. Not an overstatement or understatement.
  • The historical cargo-fmt string-corruption defect is not present at this head in either affected
    file, and two independent regression tests now guard against recurrence.
  • Re-fetched the PR ref directly rather than trusting the tweaker's rebase report; the security-
    relevant code was read from that exact commit object and is intact post-rebase.

One non-gating residual, logged for the dig_ecosystem#3198 producer review rather than gated here:
this code has no way to verify that a future reflexive_addr genuinely names the dig-peer socket rather
than some other socket -- it trusts whatever the producer reports. Not exploitable today because that
producer does not exist yet (reflexive_addr is unconditionally null), confirmed directly against
origin/main rather than assumed from the PR's own claim.

Process note, not a gating security finding: gh pr view reports mergeStateStatus DIRTY /
mergeable CONFLICTING at the time of this audit -- caused by a same-line Cargo.toml/Cargo.lock version
collision (main advanced to 0.254.81 after this branch's merge-base; this branch is at 0.254.82, so
numerically correct but textually conflicting on the same line). This needs one more rebase before
merge; it does not touch any of the security-relevant files reviewed above.

What I could not validate end-to-end: this PR's derived-advertisement path cannot be exercised on a
real host today, because dig-node-core still hard-codes reflexive_addr to null (confirmed directly).
Every guard above is verified by direct code inspection and by the PR's own unit/integration tests
against synthetic addresses, not by a live run. That is the correct and honest state to ship in --
the safe default (advertise nothing) is what every real host gets until dig_ecosystem#3198 lands --
but it is not the same as having watched a real corroborated address reach a coin.

No edits, merges, or undrafts performed. No shared checkout was mutated -- read via
git show/git fetch only, no checkout/reset run.

MichaelTaylor3d and others added 8 commits September 5, 2026 03:34
…er address


The mirror advertisement defaulted to nothing until an operator set
DIG_MIRROR_ADVERTISE_URLS, so a node that had never been configured created no
coin and earned nothing. The node can learn one address about itself -- the
reflexive mapping of its dig-peer socket -- and this makes that the default.

- advertise::effective_urls decides one pass: an operator value overrides,
  otherwise the reflexive address in dig:// form, IPv6 first, gated on a held
  relay reservation or a confirmed direct mapping.
- The four ways of publishing nothing are named states rather than one empty
  list, because they have four different remedies.
- create refuses with the state's own sentence instead of a fixed "none is
  configured".

Refs DIG-Network/dig_ecosystem#3197

Co-Authored-By: Claude <noreply@anthropic.com>
…feiture not slashing


25.10 said the advertised URL "MUST NOT be derived", which the node now does --
so the clause is rewritten rather than left contradicting the code it governs.

It also inherited a wrong reading of what an unreachable advertisement costs.
25 says "penalised" and never defines a mechanism; the mechanism lives in
dig-mirror-coin and has no slash path. Reclaim is owner-key-only, returns the
full amount, and no path reduces $DIG supply, so the cost is that epoch's
rewards, capital locked until reclaim, and a reclaim fee -- never principal.
That is the same cost as advertising nothing, which is why refusing to derive
bought nothing.

Adds the five-state taxonomy, and names the trap of reading getNetworkInfo's
`reachability` as a confirmed direct mapping: it reports "direct" for the
absence of a relay, which would make the liveness gate vacuously true for
exactly the nodes it exists to stop advertising.

Refs DIG-Network/dig_ecosystem#3197

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


Co-Authored-By: Claude <noreply@anthropic.com>
…ration suite's module doc


Co-Authored-By: Claude <noreply@anthropic.com>
Assigned by the orchestrator, which alone can see the sibling claim: PR #561
(fix/3198-public-stun-fallback) has taken 0.254.79.

Minor, not patch-only reasoning: the mirror advertisement gains a derived
default where it previously had none, which is a new capability rather than a
fix -- but the workspace line moves by patch here because the crate's own
compatible surface is unchanged for every already-configured node, whose
behaviour is byte-identical to what shipped.

Also guards the create refusal's RUNTIME VALUE against an eaten `\` string
continuation. cargo fmt has been observed collapsing one while keeping the
indentation, baking a literal run of spaces into the middle of a sentence;
cargo fmt --check is satisfied by that, and a source grep misses it because
the backslash is already gone.

Co-Authored-By: Claude <noreply@anthropic.com>
…reaches a coin


relay.dig.net began answering STUN today and reports its load balancer's
address rather than the caller's (relay.dig.net#11): ten well-formed answers
out of ten, correct magic cookie, matching transaction id, an address inside
AWS's range that serves nothing. Nothing errors. dig-node prefers the relay
tier, so that is the answer a node gets right now -- and this change would
have written it into a mirror coin, on chain, permanently, with collateral
locked behind it.

Two gates, neither of which an operator's typed value faces:

- AGREEMENT. A reflexive reading now carries its SOURCE, and only an address
  two DIFFERENT sources report is usable. No amount of checking one answer
  can see the defect above; a second source disagrees immediately. This is
  NC-12's shape -- untrusted sources that must agree, never one that is
  trusted -- applied to the node's reading of itself.
- ROUTABILITY. A derived private, CGNAT, documentation, benchmarking or
  reserved address is a broken reading rather than a choice, and is refused.
  An operator's LAN address is still accepted, because that IS a choice
  (SPEC.md 25.10).

No provider range is special-cased and none should be. Blocking the AWS block
would paper over one instance of a general defect and would be wrong for every
node legitimately running on EC2, which many will.

The second source is NOT built here -- it needs provenance plumbing through
dig-node-core's peer seam, which a sibling lane owns. What is built is the
requirement, so an uncorroborated address cannot reach a coin while that lands.

Also repairs a `\` string continuation that cargo fmt collapsed into fourteen
literal spaces mid-sentence. cargo fmt --check is satisfied by that, since the
formatter produced it; only the operator-facing-line guard, which asserts on
the composed VALUE, could see it. The repaired message uses concat! so no
later formatter pass can do it again.

Refs DIG-Network/dig_ecosystem#3197
Refs DIG-Network/relay.dig.net#11

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


Two normative additions, both because a source can report a wrong address
without erroring (relay.dig.net#11):

- Two DIFFERENT sources MUST agree before a derived address is published.
- A derived address MUST be global unicast, which is stricter than the rule
  on an operator's entry: their LAN address is a choice, a derived one is a
  broken reading.

Also states that no address range may be special-cased in either direction --
excluding the range a misconfigured server happens to report would refuse
every node legitimately hosted there -- and adds the sixth state,
uncorroborated_address, which is distinct from no_public_address because the
node is missing a second answer rather than a first one.

Refs DIG-Network/dig_ecosystem#3197
Refs DIG-Network/relay.dig.net#11

Co-Authored-By: Claude <noreply@anthropic.com>
The module doc and SPEC 25.10 said the relay reports its load balancer's
address instead of the caller's, flatly. Re-measured from two independent
external vantage points: an IPv6 caller gets the CORRECT answer 3 out of 3,
and only an IPv4 caller gets the balancer's address. The cause is an
address-family crossing -- preserve_client_ip is mandatory for UDP ip-target
groups and AWS documents it as having no effect on traffic converted from
IPv6 to IPv4 -- so relay.dig.net#12 fixes it with a same-family listener.

The correction matters because the first reading invites a wrong guard. IPv6
is both the WORKING case here and the 5.2-preferred one, so a rule shaped as
"distrust IPv6 from the relay" would discard good discovery while keeping the
bad answers. Neither the family nor the owning range is a rejection criterion
here, and both are now stated as rules rather than left to be inferred, with
a test pinning the IPv6 half using an address in the very range that surfaced
the defect.

The guard itself needed no change: corroboration compares addresses and never
families, and the routability gate excludes ranges by what they denote, not by
who owns them -- an AWS address two sources agree on already publishes, and
that was already a test.

Records what this layer does NOT check and why, now that dig-node#566 owns it:
the sharpest discriminator is whether the answer describes the caller, and
`reported_port == the socket's own source port` must not be lifted here. A
NAT'd node's reflexive port legitimately differs from its local source port --
that is what NAT port mapping IS -- so the comparison made anywhere but the
STUN client would refuse the exact population reflexive discovery exists for.

Refs DIG-Network/dig_ecosystem#3197
Refs #566
Refs DIG-Network/relay.dig.net#12

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/3197-wu2-reflexive-mirror-url branch from 792f4ea to 0c556c1 Compare September 5, 2026 10:36
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review September 5, 2026 10:58
@MichaelTaylor3d
MichaelTaylor3d merged commit 86ca409 into main Sep 5, 2026
14 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/3197-wu2-reflexive-mirror-url branch September 5, 2026 10:58
MichaelTaylor3d added a commit that referenced this pull request Sep 5, 2026
…nfo (#568)

* fix(peer): wire the discovered reflexive address into dig.getNetworkInfo

reflexive_addr was hard-coded Value::Null in Node::network_info, so #561's
tiered STUN discovery (operator -> relay -> public) never reached the field
PublicAddress::from_network_info reads. A mirror bond therefore always saw
"nothing has reported an address" no matter what the peer seam discovered,
which is why every bond on a real host sat unadvertised with $DIG locked
and zero earned.

The null was not a deliberate policy -- the producer simply did not exist
before #561, and dig-node-service's advertise.rs was already written and
tested against the eventual shape ("the network_info adapter reads the
address, the provenance and the relay ... all three shapes").

- PeerStatus gains a reflexive slot (SocketAddr, tier label), set once from
  the peer-network bring-up's StunPlan::discover_reflexive result, mirroring
  the existing peer_id/last_error pattern on the same struct.
- network_info() now reads it and publishes reflexive_addr as a one-element
  [{"source", "addr"}] array rather than a bare string -- the shape
  PublicAddress::from_network_info requires to make the reading eligible
  for future corroboration (dig-node#566), not one that can never agree
  with a second source.
- candidate_addresses (and therefore listen_addr, and the `candidates:`
  line dign network-info renders) now also folds the reflexive address in
  via the already-existing advertised_socket_addrs_with_reflexive, closing
  a second gap: the field was previously built from the WITHOUT-reflexive
  helper regardless of what the field itself reported.
- A null reflexive address remains a real, honest, fail-closed state: no
  STUN tier having answered still reports null, never a fabricated or
  stale value.

This wires the value through; it does not grant it authority. #562's
corroboration and global-unicast-routability guards in advertise.rs are
unchanged and still gate whether a derived address can be staked into a
mirror coin.

Closes #567

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

* docs(spec): document dig.getNetworkInfo's reflexive_addr wire shape (§25.10)

Normative text for the shape connected in the previous commit: null before any
STUN tier has answered, else a one-element [{"source","addr"}] array per
reading, never a bare string or bare list -- either of which carries no
reporter identity and can never satisfy the corroboration rule this same
section already requires.

Refs #567

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

* style: cargo fmt the reflexive-address test assertion

Refs #567

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

* docs(tests): fix comments this PR makes false in mirror_advertised_urls.rs

Three comments asserted "dig-node-core hard-codes reflexive_addr to null" /
"this is every host's state today" as a blanket fact. #567 makes that false
in general -- it is now only true on a relay-less/offline host or one still
mid-bring-up. The tests themselves are unaffected (they already construct
PublicAddress directly, never through Node::network_info), so only the
prose needed correcting, not the fixtures.

Refs #567

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

* docs(mirror): fix advertise.rs comments this PR makes false

Three more places said "reflexive_addr is hard-coded null in dig-node-core
today" / "until dig_ecosystem#3198 lands a producer" as present-tense fact.
#561 already landed the producer and #567 (this PR) wires it into
dig.getNetworkInfo, so both framings are now wrong in a way a future reader
would act on -- "the producer does not exist yet" would send someone to
re-build what #567 just connected.

No test fixtures changed: every test here already constructs PublicAddress
directly and never through Node::network_info, so the corrected prose
describes what remains true (null is a real, reachable state on a
relay-less/offline host or pre-answer) rather than what to build next.

Refs #567

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

---------

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