Uh oh!
There was an error while loading. Please reload this page.
feat(custody)!: carve node-side USER custody out of dig-node - #326
Conversation
WIP stub so the lane survives a cap. Refs dig-node#325, dig_ecosystem#1701. Co-Authored-By: Claude <noreply@anthropic.com>
Deletes crates/dig-wallet/src/sage/auth.rs whole and every reference to it: the UnlockAuth field/builder/accessor on WalletBackend, the auth.* and wallet.* dispatch arms, dispatch_auth, dispatch_custody, refresh_observed_derivations, and the SignGrantGuard that existed only to consume a one-shot signing grant. resolve_signer no longer reaches custody, so the only signer a backend can hold is the one a test injects via with_signer. Refs dig-node#325, dig_ecosystem#1701. Co-Authored-By: Claude <noreply@anthropic.com>
…w (dig-node#325) Removes every path from this node to a user's private key: create/import/restore/ unlock/lock/select/delete/reveal_mnemonic/signer/verify_password/sign_once and the seed helpers behind them (provision, read_seed, build_signer, cache_wallet_facts, canonicalize_legacy, master_secret_key, the HD derivation window). What survives is the non-secret manifest read the chain-sync supervisor needs: any_wallet, list, status and custodied_public_keys. CustodyState loses its Unlocked variant because no code can produce it any more. The wallet gate now classifies wallet.*/auth.* as Retired and refuses them outright rather than letting an unhandled name fall through to the open class. Refs dig-node#325, dig_ecosystem#1701. Co-Authored-By: Claude <noreply@anthropic.com>
…he spec Rewrites SPEC.md 7.12/18.20/18.20a/18.21/18.22/18.24 to describe what the node now does, replaces the authz custody tests with retirement tests that pin the refusal from both sides, and deletes the live-funds tip e2e whose premise was importing a funded mnemonic into node custody. Names the gap rather than papering it: dig-wallet's self-origin wallet UI still seals a user seed and signs (dig-node#327), so 908 is satisfied on the Sage-parity plane and not yet ecosystem-wide. Refs dig-node#325, dig-node#327, dig_ecosystem#1701. Co-Authored-By: Claude <noreply@anthropic.com>
…e-out
Names control.wallet.{balance,coins,peak,syncStatus,broadcast} individually and
checks both facts a served method needs: published in CONTROL_METHODS, and routed
in OWNED_CONTROL_METHODS so dispatch_owned has a real arm for it. Paired with a
control that fails if the list is emptied or the haystack accepts any name.
Bumps to 0.144.0 (minor: this repo bumps minor for a breaking 0.x change, as the
freeze did at 0.143.0).
Refs dig-node#325, dig_ecosystem#1701.
Co-Authored-By: Claude <noreply@anthropic.com>…ved plane build_assembles_a_served_backend now asserts the retired namespace reaches no handler ON THE PRODUCTION ASSEMBLER, which is a stronger statement than the same check on a hand-built backend. The persistence test probes the watch list instead of a custodied wallet - the better probe anyway, since on a 908 install the registered keys are the only source of a subscription set. Refs dig-node#325, dig_ecosystem#1701. Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
commented
Aug 23, 2026
Gate in progress — interim evidence (head |
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
PASS — correctness + security gate (full tier)
Head reviewed: 5a8cbf8300ce51da548d6d886836fdacedc42f5d (resolved from the remote, matches dispatch).
A ~3,700-line deletion on a custody path is exactly where a gate should be adversarial, so every claim below was reproduced rather than read. Verdict: the carve-out is safe, the guard is strictly stricter than what it replaced, and the SPEC does not overclaim.
1. The KEEP list — all four survive
| item | evidence |
|---|---|
dig-wallet crate + dig-node-service dep | crates/dig-node-service/Cargo.toml:113 (and :225 for the test target); control.rs still carries 56 dig_wallet references |
the five control.wallet.* chain reads | control.rs:3877-3894 pins them by name in both CONTROL_METHODS and OWNED_CONTROL_METHODS |
sync_supervisor watched-address source | sync_supervisor.rs:635impl PuzzleHashSource for WalletCustody, :671UnionPuzzleHashSource, :695followed_puzzle_hashes; wired at service.rs:232 |
seed_store.rs / autoseed.rs | zero diff against origin/main |
On the owned-membership reasoning, which I was asked to check specifically: it holds.dispatch_owned's _ arm is unreachable!() at control.rs:919, documented as unreachable BY CONSTRUCTION at :857-858, and the #711 lockstep partition test drives it. So asserting membership in OWNED_CONTROL_METHODS really is a routing guarantee and not a discovery claim — a name in that list with no arm reddens the partition test rather than silently resolving to nothing. Asserting CONTROL_METHODS alone would not have carried that, and the test correctly asserts both.
the_light_client_guard_would_notice_a_missing_read (control.rs:3903) is a real control: it pins the array length at 5 and proves the haystack rejects an arbitrary name, so the loop cannot go vacuous by being emptied or misspelled.
2. Guards did not loosen — reproduced, in my own worktree at head
This was the subtle risk (dropping the prefixes sends wallet.* to Other, which means open). Both claimed mutations reproduce exactly:
- Delete
authorize'sRetiredearly return (wallet_authz.rs:204-207) →every_retired_custody_name_is_refused_with_any_tokenFAILS:wallet.create: refused with Some("master-token-value"). classify's prefix match →if false(wallet_authz.rs:156) → that test FAILSleft: Other, right: Retired, andunpaired_caller_is_denied_on_every_gated_methodFAILS too.
Restored and re-verified green (12/12) after each.
Two properties beyond the mutations that make this more than an outcome assertion:
RETIRED_METHODS(wallet_authz.rs:240-259) includeswallet.a_name_that_never_existedandauth.a_name_that_never_existed. That is the difference between naming the property (the prefix is closed) and naming an outcome (these nine old names are closed) — an implementation that enumerated only the historical names and left the prefix open would pass the weaker test and fail this one. That is precisely the fail-open the nextwallet.*method would have walked into.the_retirement_does_not_catch_the_control_plane_wallet_reads(:305) is the both-directions half: it draws its haystack from productionCONTROL_METHODS(not a local literal) and refuses to pass with fewer than 5 reads, so a substring-rather-than-prefix retirement — which would have refused the light client outright while every custody test stayed green — reddens here.
Answer: no. No retired custody name is reachable with any token, including the master token, and the refusal is evaluated before any token is examined so it cannot be weakened by a pairing or a master-token bug.
meta.rs now sources its discovery prefixes from wallet_authz::RETIRED_CUSTODY_PREFIXES rather than restating them — the right call, since discovery and authorization drifting apart is the failure that makes a name both advertised and unreachable.
3. The surviving signing surface, and the SPEC's honesty
Confirmed present, exactly as described, in crates/dig-wallet/src/lib.rs at head: seed_path():200; routes registered :3825 /api/import, :3826 /api/unlock, :3828 /api/send; the CHIP-0002 signer dispatch :1310 chip0002_signCoinSpends (declared :1107); seed reads at :414 and :1033.
Not removing it was the correct call. The step-2 zero-population count was taken over the custody manifest, not over seed_path(), so a blind deletion here is exactly the funds-loss risk the freeze-then-count sequence exists to prevent. Filed as #327 (count first) — verified open.
The SPEC is honest. §18.20 (SPEC.md:5406-5421) scopes its structural claim explicitly — "This is STRUCTURAL for the SAGE-PARITY plane", qualified to "no path through crate::sage" — and then, under its own heading "§908 is NOT yet whole, and the remainder is named", names the surviving crate::lib surface by route and by method, calls it a RIVAL implementation of the capability removed here, states it was never frozen, and states the measurement gap that is the reason it survives. There is no unqualified "the node signs nothing" claim anywhere in the changed text. The earlier overclaim is genuinely corrected.
Answer: yes, honest.
4. Also checked
- Five required checks green by name at this head via
check-merge-preconditions.sh --allow-draft(Lint commit messages, Check version increment, Rustfmt, Clippy, Test + coverage),mergeStateStatus=CLEAN,unresolvedReviewThreads=0.Test + coverageis the authority for the 2,256/0 figure; my own full-workspace run exceeded my local time budget and I am relying on the required check rather than asserting a number I did not see finish. - ENOSPC truncation risk on
custody.rs: ruled out structurally.dig-walletcompiles at head (I built it), which a truncated or incoherent 449-line file cannot do. The file terminates properly on therestrict_permissionscfg pair. - No
--theirs-style reversion.SPEC.mdis +110/-272 and the set of numbered section headings is identical toorigin/main— nothing was lost wholesale. LF preserved: zero CR bytes inSPEC.md,wallet_authz.rs,custody.rs. - Removing the live-funds e2e and its runbook is right: its premise was importing a funded mnemonic into node custody, which no longer exists.
- Extension sweep correctly dropped (that version is deprecated); docs.dig.net#79 covers the docs half.
Non-gating notes (resolved by me, not blocking)
- §2.4b
dig-*/chia-*dep refresh not performed. Defensible here and I am not gating on it: this is a pure-deletion carve-out on a custody path, and folding dependency churn into a 3,700-line removal widens the blast radius of the one PR where that is least welcome. Worth taking on the next dig-wallet touch. - dig-constants check — asked, both directions, clear. (a) Nothing here belongs in
dig-constants:RETIRED_CUSTODY_PREFIXESis node-local authorization policy with no second implementation to match, andseed_path()is a local on-disk layout. (b) Nothing here should be consumingdig-constants: the diff introduces no asset id, address, puzzle hash, port, genesis value or version pin. Net effect is the removal of local constants, not the addition of any.
PASS. Do not undraft or merge — that is the orchestrator's call.
Uh oh!
There was an error while loading. Please reload this page.
Carves node-side USER custody out of
dig-node— step 4, the last step, ofhttps://github.com/DIG-Network/dig_ecosystem/issues/1701.
Closes#325
Steps 1–3 are done: the freeze merged (
2ce8002c, #321), and the affected population COUNT is ZEROacross four machines on two independent instruments — so step 3's offline export CLI was skipped per the
parent ticket's own condition. This removal destroys no key material on any machine in the population.
This is a carve-out, not a dependency drop
The parent ticket's step 4 says "drop
dig-walletfromdig-node-service". That predates the lightclient and would break it:
control.rsholds 56dig_walletreferences servingcontrol.wallet.{balance, coins, peak, syncStatus, broadcast}, andWalletCustodyis the node's ownwatched-address source inside
sync_supervisor. Both are KEPT.Blast radius checked
gitnexus analyzeon this worktree (11,842 nodes / 32,457 edges / 300 flows), thenimpact --direction upstreamper symbol, corroborated with ripgrep because gitnexus under-counts call graphs. The index wasdeleted afterwards (§1.6).
WalletBackend::dispatch_authWalletBackend::dispatch_custodyWalletBackend::refresh_observed_derivationswallet_authz::classifyUnlockAuth::sign_unlockWalletCustody::sign_onceWalletCustody::reveal_mnemonicWalletBackend::with_custodyWARNING — the three CRITICAL results are why this PR is shaped the way it is. Their single direct
caller is
WalletBackend::dispatch, the ONE handler set both loopback transports share, so everySage-parity read — including the light client's — routes through the same function. The blast radius is
therefore the whole wallet surface, and the change was scoped to removing two match arms from that
dispatch rather than restructuring it.
classifyis HIGH for the same reason on the authorization side.detect_changes()was not run: the index was deleted under the disk pressure noted below, and re-indexingto answer a question the diff already answers was not worth 120 MB on a volume that hit ENOSPC mid-lane.
The diff is stated in full below instead.
Removed
crates/dig-wallet/src/sage/auth.rs— deleted whole (980 lines). All line numbers are against2ce8002c.UnlockAuth(+new,mode,set_mode,method,status,enroll_totp,set_method_password,enroll_passkey_begin,enroll_passkey_finish,unlock,sign_unlock,lock,effective_signer,consume_sign_grant)auth.rs:219–449UnlockMode,AuthMethod,AuthSessionState,Credential,TotpEnrollment,PasskeyCredential,AuthStatus,AuthConfig,Runtimeauth.rs:66–199crates/dig-wallet/src/sage/custody.rs— 2,030 → 415 lines.WalletCustody::create/import/restorecustody.rs:384/398/416WalletCustody::unlock/lock/select/deletecustody.rs:432/457/465/476WalletCustody::reveal_mnemonic/signer/verify_password/sign_oncecustody.rs:502/509/523/538WalletCustody::new(network + derivation args) →WalletCustody::open(config_dir)custody.rs:289WalletCustody::observe_occupied_puzzle_hashescustody.rs:312provision,check_password,read_seed,build_signer,cache_wallet_facts,canonicalize_legacy,reconcile_legacy_same_key,wallet_exists,info_forcustody.rs:573–1050master_secret_key,encode_public_keys,wallet_fingerprint,p2_puzzle_hashcustody.rs:1129–1176DerivedWindow(+extend_to,highest_occupied_index,into_signing_keys)custody.rs:1055Networkenum +agg_sig_data,Unlocked,WalletRefcustody.rs:139/252/188MIN_PASSWORD_LEN,DEFAULT_DERIVATION_COUNT,DERIVATION_GAP_LIMIT,MAX_DERIVATION_COUNTcustody.rs:91–124CustodyState::Unlocked(variant)custody.rs:159crates/dig-wallet/src/sage/rpc.rs— 738 lines removed.WalletBackend::dispatch_authrpc.rs:4649WalletBackend::dispatch_custodyrpc.rs:4552WalletBackend::refresh_observed_derivationsrpc.rs:4514WalletBackend::with_auth/auth()/ theauthfieldrpc.rs:730/736/602SignGrantGuard+ itsDroprpc.rs:4845m if m.starts_with("wallet.")and"auth."dispatch armsrpc.rs:4455/4462resolve_signerrpc.rs:2789–2794Elsewhere:
wallet_authz::{CUSTODY_PREFIX, AUTH_PREFIX, WalletMethodClass::Custody};sage::service'sUnlockAuthconstruction and.with_auth(auth);crates/dig-node-service/tests/live_funds_tip_e2e.rsand
runbooks/live-funds-tip-e2e.md(their premise was importing a funded mnemonic into node custody).KEPT, and confirmed working
dig-walletcrate and itsdig-node-servicedependency — untouched.control.wallet.{balance, coins, peak, syncStatus, broadcast}are in both
CONTROL_METHODSandOWNED_CONTROL_METHODS, pinned by name in a new test(
the_light_client_chain_reads_survive_the_custody_carve_out) plus a control that fails if the list isemptied or the haystack accepts arbitrary names.
dispatch_owned's_arm isunreachable!()byconstruction, so the pre-existing lockstep test reddens if an owned name loses its handler — membership
in the owned list is therefore a real routing guarantee, not just discovery. The integration test
the_chain_reads_are_open_reachable_and_degrade_honestlypasses unchanged.sync_supervisor's watched-address source.impl PuzzleHashSource for WalletCustody,UnionPuzzleHashSourceandfollowed_puzzle_hashesare unchanged. All five #2609/#2823 supervisortests still run, including the two that distinguish "no wallet" from "enrolled but unreachable".
seed_store.rs— untouched. It loses one caller and keeps two:autoseed.rs(the node's ownDIGOP1/DIGVK1operator identity, which no ratification retires) andlib.rs(below).a_restarted_locked_node_still_refuses_a_bundle_over_its_non_primary_keystillruns; it now enrols the manifest from public keys and signs the bundle elsewhere, which is a more
faithful fixture — a pre-signed bundle arriving from outside is exactly the push it must catch.
A signing path SURVIVES — named, and filed
crates/dig-wallet/src/lib.rsis a second, independent node-side USER custody + signing surface (theself-origin wallet UI, SPEC §16.3), and this PR does not remove it:
lib.rs:369POST /api/import—seed_store::encrypt_seed(&m, &req.password)→seed_path()lib.rs:409POST /api/unlock—seed_store::decrypt_seed(...)→ signing sessionlib.rs:510POST /api/send— builds and BLS-signs a payment;broadcast:truepushes to mainnetlib.rs:1049+ — the CHIP-0002 dapp signer (chip0002_signMessage,chip0002_signCoinSpends,chia_signMessageByAddress)It is a rival implementation of the capability removed here and the freeze never covered it —
2ce8002ctouched onlysage/auth.rs,sage/custody.rs,sage/rpc.rs,seed_store.rs,wallet_authz.rsandmeta.rs.It was deliberately left in place, because removing it would be unsafe today. The step-2 zero count
was taken over the custody manifest (
<config_dir>/wallets/), not overseed_path()— so deletingthis surface without its own count carries exactly the silent funds-loss risk the parent ticket's
freeze-then-count sequence exists to prevent. Filed as #327, which starts with the
count.
Consequently
SPEC.md§18.20 states that §908 holds structurally on the Sage-parity plane and is notyet whole, rather than claiming it is satisfied. An earlier draft of this PR overclaimed that; it was
corrected.
§908 on the plane this PR owns
Structural. Nothing in
crate::sagecan read a.seedfile, decrypt one, derive a secret key, or build aWalletSignerfrom user material.resolve_signernow returns only the bring-up-injected signer(
with_signer), which no shipped build attaches — it exists for the simulator/test path — so everyrequire_signer()call site reports the wallet locked. That also means the node's own auto-tip spendcannot execute on a shipped node; it already could not, since it drew its signer from a custody session
and the custodied population is zero, but it is now absent rather than merely empty.
What the compiler proved vs. what needed a test
Compiler-proved (a call to a deleted item does not compile, so no test can add to it): every symbol in
the three tables above.
cargo check --workspace --all-targetslocated every consumer — that is how thefive
sync_supervisorfixtures, the twoservice.rstests, therpc.rscustody/auth blocks and thelive-funds e2e were found, rather than by search.
Needed a test, because a guard is involved and a guard can be wrong while compiling:
every_retired_custody_name_is_refused_with_any_token— the deletion could have made the gate LOOSER.Removing the prefixes from
classifysendswallet.*toWalletMethodClass::Other, whose meaning is"open", so the next
wallet.anythinganyone adds would be unauthenticated by default. The test assertsthe class is
Retired, asserts explicitly that it is notOther, and refuses all four token states(none / wrong / master / paired). It includes
wallet.a_name_that_never_existedandauth.a_name_that_never_existedso an implementation that enumerated only the nine old names — whichwould leave the prefix open — cannot pass it.
the_retirement_does_not_catch_the_control_plane_wallet_reads— the opposite error. A retirementwritten as a substring rather than a prefix match would refuse
control.wallet.balanceand the nodewould stop answering for money while every custody test stayed green. Its haystack is the production
CONTROL_METHODSlist, not a literal, and it fails if fewer than five reads are found.build_assembles_a_served_backend— asserts 404 forwallet.status,wallet.create,auth.statusand
auth.sign_unlockthrough the production assembler.wallet.statusis in the list on purpose:it was the most innocuous name in the namespace, a read, so removing only the dangerous-looking methods
reddens here.
the_retired_custody_surface_is_absent_from_every_discovery_artifact(repointed from the freeze) —now sources its prefixes from
wallet_authz::RETIRED_CUSTODY_PREFIXESrather than restating them, sodiscovery and authorization cannot drift into disagreeing about which names are retired.
Mutation proof, run (working tree restored from a file copy afterwards — never
git stashin thisrepo):
authorize'sif class == Retired { return false }every_retired_custody_name_is_refused_with_any_tokenFAILED — "wallet.create: refused with Some("master-token-value")". 11 passed, 1 failed.classify's prefix match unreachable (if false)every_retired_custody_name_is_refused_with_any_tokenandunpaired_caller_is_denied_on_every_gated_methodFAILED — "left: Other, right: Retired". 10 passed, 2 failed."control.wallet.peak"fromCONTROL_METHODSthe_light_client_chain_reads_survive_the_custody_carve_outFAILED — "control.wallet.peak left the published control surface". 1 passed, 1 failed.The two authz mutations fail with different messages on different assertions, which is what makes
each one load-bearing rather than one test covering for the other. Restored: 12 passed / 0 failed and
2 passed / 0 failed respectively.
Test + gate evidence
cargo test --workspace --no-fail-fast: 2,256 passed, 0 failed, 1 ignored across40 suites.
build_assembles_a_served_backendandon_disk_db_and_seed_persist_across_buildsdrovewallet.status/wallet.createand got 404). Bothwere rebased rather than deleted.
freeze itself was
feat(custody)!at 0.143.0.origin/mainwas re-read at bump time and merged in(it had moved to 0.143.1 under the sibling
dig-downloadlane); the union compiles.Sweep (§1.3b)
SPEC.md§7.12, §18.20, §18.20a, §18.21, §18.22, §18.24 rewritten; the §4.8 WS authorizationparagraph, the §16.2 self-custody note and the §18.12 push-guard note corrected. LF endings preserved
(0 CR before and after).
SYSTEM.md— not touched (superproject-owned, already updated at0eb2b8a). No further changeneeded: it describes the node as holding no user key, which this PR makes true on the Sage plane. If
anything is added, it should be a pointer to dig-node#327 so the remaining surface is discoverable from
the map.
Notes for the gate
custody.rsto zerobytes mid-write. The work was redone and the removal helper now writes via a temp file with an
explicit non-empty check before
os.replace. Nothing was lost.clear the bar: fix(custody)!: a second node-side USER custody + signing surface survives in dig-wallet's self-origin UI #327 is a money/custody defect, fix(dig-node): rooted /s read survives a broken lineage walk (#747, #841) #79 is user-visible documentation.