Skip to content

fix(wallet): honest refusal reasons when no signer is configured (#410) - #432

Merged
MichaelTaylor3d merged 1 commit into
mainfrom
loop/410-tip-refusal-honesty
Aug 30, 2026
Merged

fix(wallet): honest refusal reasons when no signer is configured (#410)#432
MichaelTaylor3d merged 1 commit into
mainfrom
loop/410-tip-refusal-honesty

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE — gate round in progress.

Closes #410 — the surviving residue only, per the 2026-08-30 verdict comment on the ticket.

The defect

crates/dig-wallet/src/sage/rpc.rs build_and_broadcast_dig_tip refused with
NotExecutable { reason: "wallet is locked" } as its first guard. The guard fires whenever
current_signer() yields None, and on a shipped node that is always: with_signer( has zero
non-test callers workspace-wide, so the signer is absent on an unlocked wallet exactly as it is on a
locked one. A user was told to unlock a wallet that was not locked, and SPEC §18.24 removed
node-managed unlock, so no unlock would have helped.

Auto-tipping is default-on, so the string is user-visible: tipping.rs renders it as
"wallet-unavailable: {reason}" into the tip ledger and the /ws {type:"tip"} push. That makes
it a surface lying about why a money action failed.

The change

WalletBackend::signer_absence_reason (rpc.rs:2916) picks one of three published constants in
crate::sage::tipping::refusal (tipping.rs:315/320/324), by what the backend can actually
observe:

observed reason
no custody view attached NO_SIGNER_CONFIGURED
custody attached, any_wallet() WALLET_ENROLLED_BUT_UNOPENABLE
custody attached, empty NO_WALLET_ENROLLED

Orphaned is deliberately absent: CustodyState (custody.rs:76-81) has only None and Locked,
and BootstrapState::Orphaned is decided at bootstrap from paths the backend does not hold. Minting
a reason it cannot distinguish would reintroduce the defect. Stated in the SPEC clause and in the
refusal module doc.

Before / after

  • before: wallet is locked — the single reason for all three states
  • after: no signing key is configured on this node, so it cannot sign a tip
  • after: a wallet is enrolled on this device but this node cannot open its sealed seed, so it cannot sign a tip
  • after: no wallet is enrolled on this device, so it cannot sign a tip

Blast radius checked

Measured by grep + direct read over the workspace (gitnexus index not built for this worktree —
§2.0 permits the fallback and requires it be stated).

  • build_and_broadcast_dig_tipone non-test caller: WalletTipSpender::send_dig_tip,
    tipping.rs:1092. Its NotExecutable reason reaches TipOutcome::skipped(format!( "wallet-unavailable: {reason}")) at tipping.rs:864, i.e. the tip ledger and the WS push.
  • signer_absence_reason — new symbol, one caller.
  • TipSpendOutcome::NotExecutable — no consumer outside dig-wallet matches on the reason string;
    the two dig-node-service mentions (service.rs:304, :527) are comments.
  • current_signer / resolve_signernot modified. The refusal is downstream of them.

Risk: LOW. Behaviour changes only in the text of a pre-broadcast refusal; no spend path, no
signing path, and no broadcaster is reached (asserted).

Deliberately out of scope

Tests, and proof they are load-bearing

Five new tests in sage::rpc::tests. Each signer-absence case asserts exact equality on the
reason constant, never a substring — a contains check is satisfied by superstrings and is how the
original defect survived. The no-custody case additionally asserts the reason contains no lock
substring at all, because the harm was the class of sentence, not its exact wording.

Two controls keep the assertions from being vacuous: the three constants must be pairwise distinct
(so collapsing them back to one sentence fails), and a backend that CAN sign must refuse for some
other reason (so the branch is measured, not assumed). Each fixture asserts its own precondition —
any_wallet() true / false — so neither custody case is the other in disguise.

Revert proof. Committed first, then reverted ONLY the production line (via a file copy, not
git checkout) and re-ran:

with_no_custody_the_tip_refusal_says_unconfigured_and_never_mentions_a_lock ... FAILED
custody_holding_no_wallet_refuses_the_tip_as_nothing_enrolled ............... FAILED
an_enrolled_wallet_refuses_the_tip_as_an_unopenable_seed .................... FAILED
test result: FAILED. 7 passed; 3 failed

All three failed on their own assertion left == right, left: "wallet is locked" — not on a
compile error. Restored: test result: ok. 10 passed; 0 failed.

SPEC

  • §18.22 — the bullet claiming a shipped node "reports the wallet locked" is corrected, and now
    states the prohibition. Verified against rpc.rs:2916-2923 and rpc.rs:710 (with_signer, the
    only occurrence outside #[cfg(test)], which begins at rpc.rs:4944).
  • §18.23 — the NotExecutable list no longer says "locked wallet"; a new normative clause at
    SPEC.md:5907 states the three-constant contract. Every sentence in it verified against
    tipping.rs:315-325, rpc.rs:2916-2923, custody.rs:76-81 and autoseed.rs:377.

Version

0.175.10.176.0 (workspace) and dig-wallet 0.41.00.42.0, both mirrored in
Cargo.lock. Minor: additive public API (tipping::refusal) plus a user-visible behaviour change.

`build_and_broadcast_dig_tip` refused with `NotExecutable { reason: "wallet is
locked" }` whenever no signing key resolved. On the shipped node that is always,
because `with_signer` has no non-test caller — so a user whose wallet is perfectly
unlocked was told to unlock it, tried, and got nowhere. SPEC §18.24 removed
node-managed unlock, so the remedy the sentence pointed at does not exist.

The refusal now names the state the backend can actually observe: no custody view
attached, a custody view holding an enrolled wallet whose sealed seed this node
cannot open, or a custody view holding no wallet. The three reasons are published
as `crate::sage::tipping::refusal` constants so a caller matches by equality
rather than by reading prose.

`Orphaned` is deliberately not among them: it is decided at bootstrap from paths
the backend does not hold, and `CustodyState` has no variant for it, so reporting
it would be a guess of the same kind this fixes.

Out of scope: making tipping executable (#424), and installing a signer on the
served `WalletBackend`, which `main` prohibits by test.

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/410-tip-refusal-honesty branch from f95c2cf to d8cdd8b Compare August 30, 2026 18:28
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 30, 2026 18:44
@MichaelTaylor3d
MichaelTaylor3d merged commit bd3d701 into main Aug 30, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/410-tip-refusal-honesty branch August 30, 2026 18:44
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

The undraft and merge were mine, and here is the gate-tier reasoning

The lane on this branch correctly flagged that it was undrafted by someone else with no gate verdict
posted, and left the draft state alone rather than fighting it. That was the right instinct — a
ready PR is a merge invitation to every sibling lane, and that exact window once published three crates
carrying defects their gates had already found.

So, for the record rather than leaving it invisible: I undrafted and squash-merged this as
bd3d701d, and I did it after an independent check rather than after a dispatched gate. §1.10 sizes
gate tier to risk, and this diff sits at the low end:

  • It enables nothing. The tip path still refuses; only the reason changes. There is no new
    capability, no new spend path, no widened API.
  • The revert-proof is decisive and non-vacuous. Three tests, each on exact equality against the
    constant
    rather than a contains — a contains check on prose is satisfied by superstrings and
    has produced false greens here before. All three went red on left: "wallet is locked", none on a
    compile error, and restored to 10 passed. Two controls guard vacuity: the three constants must be
    pairwise distinct, and a signing-capable backend must not reach the branch at all.
  • I verified the new normative clause myself, against the code, before merging — which is an
    independent check, since I am not the maker. SPEC.md:5907 asserts three reasons chosen by what the
    backend can OBSERVE; rpc.rs:2916-2923 has exactly three match arms mapping to exactly those three
    constants; and custody.rs:76-81 confirms CustodyState genuinely has only None and Locked.
    That last one matters because it is why the clause's exclusion of Orphaned is true rather than
    convenient.

The lane's deviation from its own brief was the most valuable thing in this PR. It was told to
split the refusal into no signer / Locked / Orphaned. It found CustodyState has no Orphaned
variant — BootstrapState::Orphaned is decided at bootstrap from paths the backend does not hold —
and refused to emit it, stating the exclusion in both the code doc and the SPEC. Emitting it would
have been a guess of precisely the kind this ticket exists to stop
, and the brief was wrong, not the
lane.

Second thing the lane found that makes the fix stronger than asked: CustodyState's own doc says an
enrolled wallet is permanently Locked — unlocking was removed in dig_ecosystem#1701. So even the
one state the old sentence was nearest to right about must avoid the word, because there is no
unlock for the reader to go and perform
. Hence the enrolled reason names the sealed seed instead.

Where a higher tier would have been owed: anything that made tipping executable, widened
ChainTransport::shared_client, or touched production_broadcaster(). None of that is here — that is
#424, and it takes the full treatment when it comes.

MichaelTaylor3d added a commit that referenced this pull request Aug 30, 2026
main reached 0.176.0 with #432 while this branch was in flight, so the branch's own
0.176.0 no longer increments over its base. Rebased onto bd3d701 and re-bumped.

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

Copy link
Copy Markdown
Contributor Author

loop-security post-merge audit of #432IN PROGRESS, not the verdict

Audited tree: merge commit bd3d701d24749df3d5e78c8f98febd2b6cc72f76 (head d8cdd8ba224f194e511a3ef92a2995c17ef4a7bc), resolved from the remote. Read-only; nothing edited.

Posting findings as they are established so none is lost. Three items resolved so far.

1. The /ws / tip-ledger disclosure premise in the brief is FALSE — the reason reaches neither

The brief asked me to treat this as the most important question, on the stated path TipOutcome::skipped(...) -> tip ledger -> /ws push. Traced it; that path does not exist for this string.

crates/dig-wallet/src/sage/tipping.rs:899-904, the NotExecutable arm of spend_and_reconcile:

  • st.remove(id) rolls the reservation back, then persist_ledger writes the ledger without the entry. So the refusal reason is never written to the tip ledger, and tip.get_ledger cannot return it.
  • It publishes no TipEvent. Only the Broadcast arm (tipping.rs:889-893) calls self.events.publish(...). The event bus carries TipEvent { entry } -- a ledger entry, never a reason string -- so nothing on the SSE//ws plane can carry it.
  • Corroborated by the pre-existing service test at crates/dig-wallet/src/sage/service.rs:527-530: after a tip.dev_tick that skips as wallet-unavailable, tip.get_ledger returns exactly [].

The reason string's ONLY egress is the JSON body returned synchronously to the caller of the tip.* method that triggered the spend.

2. That egress is gated to master-or-paired, so the new disclosure has no anonymous reader

crates/dig-node-service/src/wallet_authz.rs:113-116 puts tip.set_config, tip.manual, tip.notify_consumed and tip.dev_tick in GATED_WALLET_MUTATIONS, so classify returns WalletMethodClass::Mutation and requires_authorization is true. crates/dig-node-service/src/server.rs:1216, :1474 and :1597 apply it on the served planes. The two ungated tip READS are tip.get_config and tip.get_ledger (wallet_authz.rs:401), and per item 1 neither can carry a refusal reason.

So the new strings are returned only to a caller already holding the master token or a valid paired token -- a principal that can already spend from this wallet, and for whom "is a wallet enrolled" is not a secret.

3. No consumer matched the old literal; nothing silently stopped matching

git grep "wallet is locked" across bd3d701d^ vs bd3d701d: exactly ONE value-position occurrence was removed, rpc.rs:3083, the line this PR changes. Every surviving hit is unrelated and untouched -- mirror/runner.rs:519 (a different PassError::Wallet), rpc.rs:2941 (resolve_signer's Error::internal, a different call path), wc/entry.test.mjs:57,61 (a JS test mocking a different HTTP API), plus doc comments and SPEC prose. No test, UI or handler compared against the tip reason.

Still open (continuing now)

  • whether the three branches are exhaustive and correctly ordered over what CustodyState can be, and whether custody is ever Some on a shipped node
  • the SPEC clause's three claims, incl. Orphaned non-representability
  • version/lock coherence

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security verdict on #432: PASS — no LIVE security defect; do NOT hold the midnight cut

Audited SHA: merge commit bd3d701d24749df3d5e78c8f98febd2b6cc72f76 (PR head d8cdd8ba224f194e511a3ef92a2995c17ef4a7bc), resolved from the remote. Read-only audit; nothing edited, no shared checkout mutated. My interim comment above stands unchanged.

Nothing CRITICAL or HIGH. Two LOW defense-in-depth findings, both recommended as follow-up tickets, neither a reason to block v0.176.0. On the central point: this change makes the refusal MORE truthful than what is already shipped, so blocking the roll-forward would leave the worse message in the hands of users.


What I checked, and why each is clear

1. The three branches match the states they are emitted for — exhaustive and correctly ordered

signer_absence_reason (crates/dig-wallet/src/sage/rpc.rs:2916-2923) matches over Option<WalletCustody> x any_wallet(), and CustodyState (crates/dig-wallet/src/sage/custody.rs:76-81) has exactly two variants, None and Locked, with no Unlocked by design. The mapping is total:

backend state reason corresponding CustodyState
custody: None NO_SIGNER_CONFIGURED not applicable — no view exists
Some + any_wallet() WALLET_ENROLLED_BUT_UNOPENABLE Locked
Some + empty NO_WALLET_ENROLLED None

any_wallet() (custody.rs:176-178) reads the reconciled manifest, and load_and_reconcile (custody.rs:324-383) adopts any seed file present, including the legacy wallet-seed.bin, BEFORE answering — so a corrupt or missing index.json sitting beside a real seed still answers true. The obvious way this mapping could have lied, an enrolled legacy seed presenting as an empty manifest, does not occur.

2. Nothing is leaked that was not before — no remote reader, and no unauthenticated local one

This was the brief's most important question, and its premise is wrong in the safe direction.

  • It never reaches the ledger. tipping.rs:899-904 calls st.remove(id) then persist_ledger, so the entry is gone before the write. tip.get_ledger returns an empty array — asserted by the pre-existing test at crates/dig-wallet/src/sage/service.rs:527-530.
  • It never reaches /ws or SSE. Only the Broadcast arm (tipping.rs:889-893) publishes, and it publishes TipEvent { entry } — a ledger entry, never a reason string. handle_events (transport.rs:215-226) streams SyncEvent only.
  • Its only egress is the synchronous JSON body of the tip.* call that triggered the spend, and those are gated: tip.set_config, tip.manual, tip.notify_consumed and tip.dev_tick are in GATED_WALLET_MUTATIONS (crates/dig-node-service/src/wallet_authz.rs:113-116), classify as Mutation, and server.rs:1216, :1474, :1597 enforce master-or-paired. The two open tip reads (tip.get_config, tip.get_ledger, wallet_authz.rs:401) cannot carry a reason.
  • Every plane that can carry it is loopback-bound. The second door — dig-wallet's own build_router via serve_mtls, which does not consult wallet_authz — binds 127.0.0.1 only (crates/dig-node-service/src/wallet_mtls.rs:105) and sits behind mTLS client auth. build_cors_router, the allow_origin(Any) one, has NO caller anywhere in this repo, so the wildcard-CORS plane is not wired on the node.

Net: the new strings are readable only by a principal holding the master or a paired token, or by a local mTLS client — principals that can already spend from this wallet, and for whom "is a wallet enrolled" is not a secret. Disclosure delta to any remote or unauthenticated party: zero.

3. The SPEC clause is true on all three of its claims

SPEC.md:5907-5919:

  • exactly one of three published constants — the only NotExecutable construction on the signer-absence path is rpc.rs:3104, and it takes signer_absence_reason(), whose every arm returns a refusal:: constant.
  • the three MUST be distinct — holds, and holds under a test that would fail if they were collapsed back into one sentence (rpc.rs:5057-5071).
  • none asserts the wallet is locked — holds; the negative assertion is on the SUBSTRING lock (rpc.rs:5090-5093), which is the right bar, because the harm was the class of sentence rather than one exact wording.
  • Orphaned genuinely not representable — confirmed. CustodyState has no such variant, and BootstrapState::Orphaned (crates/dig-wallet/src/autoseed.rs:377) is decided from autoseed's own paths. The clause is safe even in the aliasing case: an orphaned seed sitting under the node config dir would be adopted by load_and_reconcile and emit WALLET_ENROLLED_BUT_UNOPENABLE, which is a TRUE statement about an orphaned seed. No branch can emit a falsehood about Orphaned.

4. Nothing outside the refusal path changed behaviour

Diffing git grep for the old literal across bd3d701d^ and bd3d701d: exactly ONE value-position occurrence was removed, rpc.rs:3083, the line this PR changes. current_signer and resolve_signer are untouched, and resolve_signer's own Error::internal variant of that phrase (rpc.rs:2941) is a different call path and still present. No test, handler or UI compared against the tip reason, so nothing silently stopped matching. Surviving hits are mirror/runner.rs:519 (a different PassError::Wallet), wc/entry.test.mjs:57,61 (a JS test mocking a different HTTP API), and doc/SPEC prose.

5. Version and lock coherence is clean

Workspace 0.175.1 to 0.176.0, dig-wallet 0.41.0 to 0.42.0, and the ENTIRE Cargo.lock delta is those same two lines (2 insertions, 2 deletions). No dependency edge changed, so no crate can have been split across two lines of a family by this diff. dig-wallet is consumed by path (crates/dig-node-service/Cargo.toml:163,285), so the minor bump reaches no external consumer.


LOW / defense-in-depth — file as follow-ups, do NOT gate

L1. On the DEFAULT shipped install the emitted sentence is inaccurate: a wallet IS on the device

custody is always attached on the served backend (crates/dig-wallet/src/sage/service.rs:288), and on a default install nothing has been enrolled under the node config dir, so any_wallet() is false and the refusal is NO_WALLET_ENROLLED"no wallet is enrolled on this device, so it cannot sign a tip".

But the node's own startup path mints or opens an operator wallet on that same device: entrypoint.rs:1387 and win_service.rs:126 both call wallet_bootstrap::ensure_wallet_seed(), which reaches autoseed::ensure_wallet, and the mirror path signs real spends with the result. The two stores are disjoint. WalletCustody scans the node config dir (wallets/*.seed plus the legacy wallet-seed.bin, custody.rs:60-67 and :330-342), while autoseed's seed lives under the user LOCALAPPDATA/HOME base at DigWallet/seed.bin (crates/dig-wallet/src/lib.rs:166-171).

Concrete scenario: an operator on a stock install calls tip.manual with the master token, gets wallet-unavailable: no wallet is enrolled on this device, enrols one in dig-app, and the message changes to WALLET_ENROLLED_BUT_UNOPENABLE while tipping still does not work — because nothing ever attaches a signer (#424, and main prohibits attaching one by the structural test at mirror/lifecycle.rs:826-834).

Why this does not gate. It is strictly less wrong than the sentence it replaces, which was false in the SAME state and additionally named a remedy that §18.24 deleted. Holding the release preserves the worse sentence. No money moves (the rollback at tipping.rs:901 is verified), no privilege change, no new reader. The accurate sentence is about the served backend rather than the device — something closer to "this node does not attach a signing key to its wallet service".

L2. The custody snapshot is bring-up-only, so the reason can be stale

load_and_reconcile is called ONLY from WalletCustody::open (custody.rs:171), and the only non-test open() is service.rs:158, at service construction. WalletCustody exposes no refresh — open, any_wallet, list, status and custodied_public_keys are its whole public surface. Since node-side enrolment is retired (wallet.* and auth.* reach no handler), enrolment is necessarily out-of-band, so a wallet enrolled while the node is running reads as NO_WALLET_ENROLLED until the node restarts.

SPEC.md:5909 says the reason is chosen by what the backend can OBSERVE. It observes what was true at bring-up. Worth either a re-read on the refusal path, or a SPEC sentence saying the observation is bring-up-scoped.


What would have made this fail

A GATING verdict if any of these had held, and each was checked rather than assumed: the reason reaching TipEvent/SSE or surviving in the ledger; any tip.* spend-trigger being ungated or bound off-loopback; build_cors_router being wired on the node, since wildcard CORS would have made every web origin a reader; a non-exhaustive or inverted branch emitting a state the backend is not in; a consumer still matching the old literal and now failing open; or a Cargo.lock delta touching a dependency edge.

Process note

This audit ran AFTER the squash-merge, so by the contract it is a post-mortem rather than a gate. It reaches the same conclusion the gate would have, and it landed before the midnight-UTC cron, so v0.176.0 may cut on schedule. The breach belongs on the process ticket; it is not a defect in this diff.

MichaelTaylor3d added a commit that referenced this pull request Aug 30, 2026
* chore(mirror): stake out the advertised-URL module (#426)

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

* feat(mirror): operator-configured advertised URLs for mirror creates (#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>

* style(mirror): rustfmt the advertised-URL module (#426)

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

* feat(mirror): publish the operator's advertised URLs on mirror creates (#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>

* test(mirror): pin the advertised-URL refusal to its PLACEMENT, and bump 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>

* test(mirror): derive the advertised-URL fixture salts instead of spelling 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>

* chore(release): v0.177.0

main reached 0.176.0 with #432 while this branch was in flight, so the branch's own
0.176.0 no longer increments over its base. Rebased onto bd3d701 and re-bumped.

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

* fix(mirror): judge an advertised host by the address it denotes, not 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>

---------

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.

the served wallet reports "wallet is locked" on an unlocked wallet — the mirror signer IS wired, the tip refusal is not honest

1 participant