fix(wallet): honest refusal reasons when no signer is configured (#410) - #432
Conversation
`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>
f95c2cf to
d8cdd8b
Compare
The undraft and merge were mine, and here is the gate-tier reasoningThe lane on this branch correctly flagged that it was undrafted by someone else with no gate verdict So, for the record rather than leaving it invisible: I undrafted and squash-merged this as
The lane's deviation from its own brief was the most valuable thing in this PR. It was told to Second thing the lane found that makes the fix stronger than asked: Where a higher tier would have been owed: anything that made tipping executable, widened |
loop-security post-merge audit of #432 — IN PROGRESS, not the verdictAudited tree: merge commit Posting findings as they are established so none is lost. Three items resolved so far. 1. The
|
loop-security verdict on #432: PASS — no LIVE security defect; do NOT hold the midnight cutAudited SHA: merge commit Nothing CRITICAL or HIGH. Two LOW defense-in-depth findings, both recommended as follow-up tickets, neither a reason to block What I checked, and why each is clear1. The three branches match the states they are emitted for — exhaustive and correctly ordered
2. Nothing is leaked that was not before — no remote reader, and no unauthenticated local oneThis was the brief's most important question, and its premise is wrong in the safe direction.
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
4. Nothing outside the refusal path changed behaviourDiffing 5. Version and lock coherence is cleanWorkspace LOW / defense-in-depth — file as follow-ups, do NOT gateL1. On the DEFAULT shipped install the emitted sentence is inaccurate: a wallet IS on the device
But the node's own startup path mints or opens an operator wallet on that same device: Concrete scenario: an operator on a stock install calls 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 L2. The custody snapshot is bring-up-only, so the reason can be stale
What would have made this failA GATING verdict if any of these had held, and each was checked rather than assumed: the reason reaching Process noteThis 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 |
* 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>
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.rsbuild_and_broadcast_dig_tiprefused withNotExecutable { reason: "wallet is locked" }as its first guard. The guard fires whenevercurrent_signer()yieldsNone, and on a shipped node that is always:with_signer(has zeronon-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.rsrenders it as"wallet-unavailable: {reason}"into the tip ledger and the/ws{type:"tip"}push. That makesit a surface lying about why a money action failed.
The change
WalletBackend::signer_absence_reason(rpc.rs:2916) picks one of three published constants incrate::sage::tipping::refusal(tipping.rs:315/320/324), by what the backend can actuallyobserve:
NO_SIGNER_CONFIGUREDany_wallet()WALLET_ENROLLED_BUT_UNOPENABLENO_WALLET_ENROLLEDOrphanedis deliberately absent:CustodyState(custody.rs:76-81) has onlyNoneandLocked,and
BootstrapState::Orphanedis decided at bootstrap from paths the backend does not hold. Mintinga reason it cannot distinguish would reintroduce the defect. Stated in the SPEC clause and in the
refusalmodule doc.Before / after
wallet is locked— the single reason for all three statesno signing key is configured on this node, so it cannot sign a tipa wallet is enrolled on this device but this node cannot open its sealed seed, so it cannot sign a tipno wallet is enrolled on this device, so it cannot sign a tipBlast 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_tip— one non-test caller:WalletTipSpender::send_dig_tip,tipping.rs:1092. ItsNotExecutablereason reachesTipOutcome::skipped(format!( "wallet-unavailable: {reason}"))attipping.rs:864, i.e. the tip ledger and the WS push.signer_absence_reason— new symbol, one caller.TipSpendOutcome::NotExecutable— no consumer outsidedig-walletmatches on the reason string;the two
dig-node-servicementions (service.rs:304,:527) are comments.current_signer/resolve_signer— not 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
WalletBackend.mainships an explicit prohibition,lifecycle.rs:854no_signer_or_broadcaster_is_ever_installed_on_the_served_wallet_backend,because installing one would switch default-on auto-tipping live as a side effect. the served wallet reports "wallet is locked" on an unlocked wallet — the mirror signer IS wired, the tip refusal is not honest #410's original
scope item 1 asked for the opposite; acting on it would break a green test on purpose.
crates/dig-node-service/src/mirror/— untouched; other lanes are live there.Tests, and proof they are load-bearing
Five new tests in
sage::rpc::tests. Each signer-absence case asserts exact equality on thereason constant, never a substring — a
containscheck is satisfied by superstrings and is how theoriginal defect survived. The no-custody case additionally asserts the reason contains no
locksubstring 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:All three failed on their own
assertion left == right,left: "wallet is locked"— not on acompile error. Restored:
test result: ok. 10 passed; 0 failed.SPEC
states the prohibition. Verified against
rpc.rs:2916-2923andrpc.rs:710(with_signer, theonly occurrence outside
#[cfg(test)], which begins atrpc.rs:4944).NotExecutablelist no longer says "locked wallet"; a new normative clause atSPEC.md:5907states the three-constant contract. Every sentence in it verified againsttipping.rs:315-325,rpc.rs:2916-2923,custody.rs:76-81andautoseed.rs:377.Version
0.175.1→0.176.0(workspace) anddig-wallet0.41.0→0.42.0, both mirrored inCargo.lock. Minor: additive public API (tipping::refusal) plus a user-visible behaviour change.