Uh oh!
There was an error while loading. Please reload this page.
feat(custody)!: remove the second node-side USER custody + signing plane - #338
Conversation
Co-Authored-By: Claude <noreply@anthropic.com>
…llet WIP: lib compiles; tests + UI + docs still to follow. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
b39044b to
d016f03CompareCo-Authored-By: Claude <noreply@anthropic.com>
…moved plane Co-Authored-By: Claude <noreply@anthropic.com>
…s on The runtime export-class guard is unreachable today: every export spelling is already outside WC_METHOD_CATALOGUE, so removing the guard leaves the suite green. Measured, not assumed. The invariant that IS load-bearing is the disjointness, and it is now pinned directly over the catalogue constant. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
A `git checkout -- Cargo.lock` during a revert-proof restored the pre-bump entries, so CI's --locked builds refused the tree while local unlocked builds regenerated it silently. Both member versions now match their manifests. Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
commented
Aug 24, 2026
loop-security — INTERIM STATUS, NOT A VERDICT. Do not merge on this comment.Audited head: Posting mid-audit so the record survives. The verdict follows as a separate comment plus inline threads. Confirmed so far (blob 2 recoverability)
A correction to my own method, recorded because it nearly produced a false cleanMy first sweep for surviving Re-run with the leading slash removed, the true picture is the good one anyway — the router at Still open at the time of writing
The rescue test has now RUN (added after the above was drafted)Cut my own detached worktree at 2 passed, 0 filtered out — the filter matched real tests rather than silently selecting nothing, and |
MichaelTaylor3d
commented
Aug 24, 2026
loop-security: PASSHead audited: No gating security defect. Two non-gating findings and one correction to the lane's report are named at the bottom. I have deliberately filed no inline threads, because an unresolved thread would deadlock a PASS. 1. THE GATING QUESTION: is blob 2 recoverable after this removal?YES — and this PR makes it MORE recoverable than the tree it replaces, not less. I accept the lane's reasoning, on stronger evidence than the lane gave. The test result, by passed-countCut my own detached worktree at
Does the fixture genuinely match blob 2's shape?Yes, on all four axes, and
The legacy read path at The test drives Does this PR remove anything that makes blob 2 LESS recoverable?No — the opposite. Every route out of that blob, before and after:
The decisive fact: the removed So blob 2 was unreachable through the surface this PR deletes, and is reachable through the CLI verb that survives, because Reachability and discoverability of the CLI verb:
Is the blob file itself untouched on disk?Yes. Verified four ways:
2. §908 — verified structural, not policedIndependently re-derived on this head, including what an enumeration misses.
3. |
MichaelTaylor3d
commented
Aug 24, 2026
Correctness gate — part 1/5: by-name audit of deleted tests (head |
| deleted (base) | restored (head) |
|---|---|
sage_mode_delegates_signing_methods_but_answers_the_handshake_locally | the_handshake_is_local_and_every_key_method_goes_to_sage |
sage_mode_dispatch_surfaces_sage_errors | dispatch_surfaces_sage_errors |
sage_mode_dispatch_routes_through_the_delegate_bridge_not_local_keys | no_persisted_setting_can_route_a_sign_method_back_into_this_process |
wallet_dispatch_self_origin_routes_through_to_the_signer | wallet_dispatch_self_origin_routes_through_to_sage |
wallet_dispatch_approved_origin_routes_through | an_approved_origins_sign_request_is_parked_for_sage_not_answered_locally |
native_mode_routes_every_method_to_the_local_signer | inverted, correctly: no_persisted_setting_can_route_a_sign_method_back_into_this_process |
settings_page_wires_the_wallet_source_control | settings_page_wires_the_sage_connection_and_offers_no_alternative_to_it |
settings_page_wires_the_new_settings_apis | settings_page_offers_no_route_to_a_key_and_says_where_the_keys_are |
(8 rows for 6 flagged — the two settings-page guards are in the same class.) The incident is also recorded in-source at lib.rs:1583-1585, which is the right place for it.
Unknown-method 501: retained and covered — the shared refusal is documented at lib.rs:672-674 (export-class and unknown-method refusals are deliberately indistinguishable on the wire, so a caller cannot probe which export spellings exist), with lib.rs:1689 and 1702 explicitly reasoning about why a bare 501 assertion would be vacuous. That is the correct treatment.
Verdict on part 1: no necessary coverage was lost. Every deleted test either lost its subject or has a named successor. No blocking finding; nothing to resolve here.
MichaelTaylor3d
commented
Aug 24, 2026
Correctness gate — parts 2 & 3/5: doc sweep, and |
| phrase | files at head |
|---|---|
NOT yet whole / not yet whole | 0 |
not yet on that one | 0 |
second custody | 0 |
native mode | 0 |
node-held seed | 0 |
WalletSource | 1 — lib.rs:1581, a comment about the removal |
seed at rest | 1 — lib.rs:9, "It used to...", retrospective |
api/export | 4 — all negations (SPEC.md:4127 "was removed", SPEC.md:5458 in the removed list, lib.rs:1476/2001 absence assertions, ui.html:634 "this UI once called") |
local signer | 3 files — SPEC.md:3669/4115 and lib.rs all negative ("no local signer", "went with the local signer it gated") |
No surviving sentence asserts the old rule. The sweep is a sweep, not an edit.
One non-gating nit, resolving myself:crates/dig-wallet/wc/entry.js:86 and wc/requester.js:147 still say "forward to the local signer". Both are untouched by this PR and describe the browser-side WC bridge posting to the loopback responder — which now forwards to Sage — so the phrase is imprecise rather than false, and neither file is normative. Not worth a round trip; noting it so a future reader does not mistake it for surviving custody.
Does the new §18.20 over-claim? No.
Every claim it makes is structural and independently checkable, and I checked the four load-bearing ones at head:
- "The process holds no unlocked-session state — the field is gone from
AppState" —lib.rs:110-114,AppStateis exactly{ approvals, delegate }. True. - "
seed_store::encrypt_seedis#[cfg(test)], so a production caller ... would not compile" —seed_store.rs:68. True. - "
wc_dispatchanswers only the keyless handshake methods and forwards every other method" — confirmed below. True. - "
GET /api/statusreportscustodiedwhile such a file exists,delegatedotherwise" —lib.rs:213-237. True.
It is also careful in the two places over-claiming would have been easy: §16.4 explicitly says §18.20 does not retire autoseed (the node's own DIGOP1 identity is a separate concern sharing an at-rest primitive), and §18.20 says the remaining WalletCustody is a read of one non-secret file. Both are the honest framing.
3. wc_dispatch — the split is right
- Keyless local arm is exactly three:
chip0002_chainId,chip0002_connect,chip0002_getMethods(lib.rs:623). Nothing keyed is answered locally;chainIdis public (wallet_dispatch_chain_id_is_public_and_returns_mainnet),connectfrom an unapproved origin parks as202(wallet_dispatch_connect_from_unapproved_origin_is_pending_202). - Everything else forwards, gated first by the per-origin consent gate: unapproved + sign method ->
403(wallet_dispatch_gates_unapproved_origin_for_sign_methods), approved -> parked for Sage (an_approved_origins_sign_request_is_parked_for_sage_not_answered_locally), self-origin ->wallet_dispatch_self_origin_routes_through_to_sage. Sage's own errors surface rather than being swallowed (dispatch_surfaces_sage_errors). - Unknown methods 501, and deliberately indistinguishable from the export-class refusal (
lib.rs:672-674) so the set of export spellings cannot be probed. The export-class check atlib.rs:658is documented as currently unreachable (lib.rs:649-655) and retained as defence-in-depth for a future catalogue addition — withexport_class_methods_are_absent_from_the_cataloguepinning the reason it is unreachable. That is the correct pairing: the guard and the fact that makes it dormant are each pinned separately, so neither can drift silently. - The tests are also honest about their own limits —
lib.rs:1625,1689,1702and1864each explain why asserting only a 501 (or only an error) would be satisfied by the wrong implementation, and assert the pump instead. That is the property-not-outcome shape.
No blocking finding in parts 2 or 3.
MichaelTaylor3d
commented
Aug 24, 2026
Correctness gate: PASSHead reviewed: Parts 1-3 are posted above (deleted-test audit; doc sweep + §18.20 over-claim check; 4. The My Stores regression — honest, remedied, does not trap
Against the
Minor, resolving myself: the anchor has no On the follow-up ticket: yes, file one — re-sourcing My Stores from Sage (or from the node's own non-secret 5. Versions, lock, CI — confirmed by name and by count
On the flaky test
Blocking findings: noneNo inline threads opened, deliberately — an unresolved thread bars merge under Merge is unblocked from the correctness leg once the PR is undrafted. I am not merging and not undrafting. |
Uh oh!
There was an error while loading. Please reload this page.
DRAFT — DO NOT MERGE. Custody change; the full triple gate has not returned.
Closes#327
Parent epic: https://github.com/DIG-Network/dig_ecosystem/issues/1701 (its last child)
Removes the second node-side USER custody + signing plane — the one inside
dig-wallet's own[[bin]] dig-walletloopback server, which the #1701 freeze never covered because thezero-population count that justified PR#326 ranged over the custody manifest
(
<config_dir>/wallets/index.json) and not overseed_path().wc_dispatchnow answers only the three keyless handshake methods and forwards every other methodto the user's Sage wallet. There is no local branch to fall through to and no setting that selects
one.
1. The population count — taken first, before a line was edited
Full measurement with commands and controls: #327 (comment 5394698931). Summary:
Machine: Windows 11,
micha, the loop's primary dev host.Ranged over: BOTH bases
seed_path()can resolve —%LOCALAPPDATA%and$HOME— not onlythe one this platform resolves. Widening past the platform's own base is what found blob 2.
%LOCALAPPDATA%\DigWallet\seed.bindig-keystore/DIGOP1origin:"auto"$HOME\DigWallet\seed.binlegacy/EncryptedSeed(v1)Total 2; user-origin 1, not 0. Blob 2 is what an
origin-keyed count misses, in the unsafedirection: it has no sidecar to read an origin from, and it cannot be auto-minted —
autoseedhasonly ever sealed
DIGOP1under a device key, there is no device key under$HOME, and the legacylayout was written exclusively by the user import/create path.
Funding, from chain state. No claim here cites
ever_funded;latch_ever_fundedhas zeroproduction callers (#286), so blob 1's
"ever_funded": falseis not evidence of anything.get_coin_records_by_puzzle_hash,include_spent_coins: true:0x00..00→success: true, 6527 recordssuccess: true, 0 records0..24→ 0 records ever, 0 unspent, every indexWhy this proceeded rather than splitting. Read literally the gate does not clear. Its stated
reason is that the offline export path (#1701 step 3) comes first — and that shipped in 0.145.0
(#331):
dig_wallet::seed_export::export_mnemonichas no network surface, reads both on-diskformats, and takes an explicit path so a file under a base this build no longer resolves stays
reachable — blob 2's exact situation. It is a real verb,
dign wallet export-seed --path <p>, routedat
entrypoint.rs:678. The precondition the gate protects is already satisfied by shipped, reachablecode, so removing the plane strands nobody.
2. Blast radius
gitnexus, per-worktree index (
dn-327, 225 files / 11,751 nodes / 32,040 edges), taken at thelane's stub commit — i.e. against the pre-edit call graph:
impact wc_dispatch --direction upstream→ 16 impacted, 7 direct, risk CRITICAL.crates/dig-wallet/src/lib.rs(its own callers plus the tests that drive them). Exactly onecrosses a crate boundary, at depth 3:
dig_wallet_rpc—crates/dig-runtime/src/lib.rs, the C-ABI FFI entrypoint the browser processcalls. It reaches
wc_dispatchthroughwallet_dispatch, whose signature is unchanged.detect_changeswas not available: this gitnexus CLI build exposes no such subcommand, and theMCP server is bound to the superproject index rather than this worktree. Falling back is permitted
(§2.0 bound 2) and is stated here. The substitutes are stronger for the "what breaks" question:
dig_wallet::*path referenced outside the crate, enumerated on both trees:sage::*(40 symbols incl.
rpc::WalletBackend),seed_export,autoseed,run,wallet_dispatch.The removed plane had zero external consumers; it was entirely private to
crate::lib.cargo clippy --workspace --all-targets -- -D warnings— clean. The compiler is exhaustivewhere a call-graph query is heuristic.
dig-node-servicebuilds againstWalletBackend, before and after. It consumes onlydig_wallet::sage::rpc::*(+seed_export,autoseed) —control.rs:820holdsArc<dig_wallet::sage::rpc::WalletBackend>. The RPC control plane is untouched by this diff.3.
autoseed.rssurvives — proven by tests, not assertedautoseedis the node's ownDIGOP1/DIGVK1operator identity: a machine credential that neverleaves the host, not custody of anyone's funds. Deleting it would break node auth rather than tighten
anything.
cargo test -p dig-wallet --lib autoseed:: → 15 passed, 0 failed.Untouched by this PR.seed_storekeeps its one production caller (autoseed), andseed_store::→ 4 passed.retire it, so a future reader does not "tighten custody" by deleting the node's credential.
4. §908 made structural, not merely policed
AppStatehas no unlocked-session field — a signer would have no material to read.seed_store::encrypt_seedis#[cfg(test)]— production code that sealed a user seed wouldnot compile. Its only remaining callers are the fixtures that prove old blobs still open.
5. Tests
dig-walletlib: 606 passed, 0 failed. Coverage 85.08% lines / 80.64% functions / 87.28%regions (floor is 80; CI gates the workspace at
--fail-under-lines 80).New
seed_export_rescue.rs(2 tests) — drives the realdignbinary against a legacyEncryptedSeedblob at a foreign base with no sidecar and no device key: blob 2's exactshape. Both nearest-wrong implementations pass a friendlier fixture and fail this one — a rescue
reading only the current container cannot decrypt it, and a rescue that resolves the default path
cannot reach it. Plus a wrong-password control asserting no fragment of the phrase appears on the
failure path.
an_approved_origins_sign_request_is_parked_for_sage_not_answered_locally— the positive half ofthe existing refusal test. This is a placement change, so the observable that must move is
where the request ends up: asserting a 501 or an empty result would be satisfied identically by a
local signer that happened to be locked, which is the state the old build sat in most of the time.
Reaching the delegate queue cannot be.
Revert-proofs — each fix reverted individually, after committing
decrypt_seedformat dispatch → keystore-onlyexport-seedignores--pathWC_METHOD_CATALOGUEunsupported_advanced_legs…FAILSSix guards restored that a compile-driven prune had silently taken. They referenced
WalletSource/Session, so they stopped compiling — but the behaviour they guard survives.Losing them would have left the export-class refusal, the unknown-method 501 and the
handshake/delegate split with no coverage at all. Re-expressed against current behaviour; two got
stronger, because the routing split was a pure-function assertion over a table that no longer exists
and is now a behavioural check that the request reaches Sage.
6.⚠️ A vacuity found and reported rather than papered over
The runtime export-class guard in
wc_dispatchis currently UNREACHABLE. Measured: disabling itleaves the entire suite green, because every export spelling is already outside
WC_METHOD_CATALOGUE, so the check below refuses all of them with an identical 501. The two checksmask each other.
This is pre-existing and was faithfully carried over from the original test, which had the same blind
spot. Handled by saying so in the code comment, correcting the test's own claim about what it proves,
and pinning the invariant that is load-bearing —
export_class_methods_are_absent_from_the_catalogue, written over the catalogue constant becausedispatch answers 501 either way and a behavioural check cannot see the difference. The guard stays,
and stays ordered first, because that ordering is what survives the change that makes it matter.
7. Docs, in the same unit of work
SPEC.md§18.20 rewritten from "§908 is NOT yet whole" to "§908 is satisfied on BOTHplanes", with the recovery contract stated.
sage/custody.rs's "not yet on that one" sentence corrected — it named this gap.route), §16.4 (autoseed is the node's own identity), and the §16 conformance row.
dig-walletanddig-runtimestill describing a local signer, anunlocked session or a wallet source.
8. UI — both surfaces made honest
Neither page may imply a capability that is gone. Settings loses the backup/restore and identity
cards and gains an explicit "this browser holds no wallet keys" statement plus a residual-seed card
pointing at the terminal command; the wallet page's four-view auth gate becomes one connect gate. The
phrase is deliberately never revealed in a browser: a page that can display it is a page that can be
tricked into sending it. Escape hatches kept — reading DIG content needs no wallet, and that is said
on both pages.
Balance is asked of Sage and an unanswered balance renders as
unknown, never0— an emptywallet and an unreachable one look identical as "0", and that is the figure a user decides whether to
spend against.
9. Versions + deps
Workspace 0.146.0 → 0.147.0,
dig-wallet0.31.0 → 0.32.0. Breaking (routes removed), minorunder 0.x. CHANGELOG is git-cliff-generated on release and deliberately not hand-edited.
§2.4b:
dig-wallet'sdig-*deps are already at latest —dig-node-control-interface 0.19,dig-offers 0.2,dig-keystore 0.9, each confirmed against the crates.io index. Nochia-*bumpis takeable here: the chia-0.36 cascade (#3152) is blocked behind #2761, and forcing one would ship
this crate split across two chia lines.
mainreaches users at 00:00Z. This gate is the last line of defence, not the first of two.