Skip to content

feat(custody)!: carve node-side USER custody out of dig-node - #326

Merged
MichaelTaylor3d merged 7 commits into
mainfrom
loop/1701-carve-custody
Aug 23, 2026
Merged

feat(custody)!: carve node-side USER custody out of dig-node#326
MichaelTaylor3d merged 7 commits into
mainfrom
loop/1701-carve-custody

Conversation

@MichaelTaylor3d

@MichaelTaylor3dMichaelTaylor3d commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Carves node-side USER custody out of dig-node — step 4, the last step, of
https://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 ZERO
across 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-wallet from dig-node-service". That predates the light
client and would break it: control.rs holds 56 dig_wallet references serving
control.wallet.{balance, coins, peak, syncStatus, broadcast}, and WalletCustody is the node's own
watched-address source inside sync_supervisor. Both are KEPT.

Blast radius checked

gitnexus analyze on this worktree (11,842 nodes / 32,457 edges / 300 flows), then impact --direction upstream per symbol, corroborated with ripgrep because gitnexus under-counts call graphs. The index was
deleted afterwards (§1.6).

symbolriskdirect callersprocesses
WalletBackend::dispatch_authCRITICAL15
WalletBackend::dispatch_custodyCRITICAL15
WalletBackend::refresh_observed_derivationsCRITICAL15
wallet_authz::classifyHIGH10
UnlockAuth::sign_unlockMEDIUM50
WalletCustody::sign_onceLOW40
WalletCustody::reveal_mnemonicLOW20
WalletBackend::with_custodyLOW41

WARNING — 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 every
Sage-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. classify is 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-indexing
to 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 against
2ce8002c.

symbolold line
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:219449
UnlockMode, AuthMethod, AuthSessionState, Credential, TotpEnrollment, PasskeyCredential, AuthStatus, AuthConfig, Runtimeauth.rs:66199

crates/dig-wallet/src/sage/custody.rs — 2,030 → 415 lines.

symbolold line
WalletCustody::create / import / restorecustody.rs:384 / 398 / 416
WalletCustody::unlock / lock / select / deletecustody.rs:432 / 457 / 465 / 476
WalletCustody::reveal_mnemonic / signer / verify_password / sign_oncecustody.rs:502 / 509 / 523 / 538
WalletCustody::new (network + derivation args) → WalletCustody::open(config_dir)custody.rs:289
WalletCustody::observe_occupied_puzzle_hashescustody.rs:312
provision, check_password, read_seed, build_signer, cache_wallet_facts, canonicalize_legacy, reconcile_legacy_same_key, wallet_exists, info_forprivate, custody.rs:5731050
master_secret_key, encode_public_keys, wallet_fingerprint, p2_puzzle_hashcustody.rs:11291176
DerivedWindow (+ extend_to, highest_occupied_index, into_signing_keys)custody.rs:1055
Network enum + agg_sig_data, Unlocked, WalletRefcustody.rs:139 / 252 / 188
MIN_PASSWORD_LEN, DEFAULT_DERIVATION_COUNT, DERIVATION_GAP_LIMIT, MAX_DERIVATION_COUNTcustody.rs:91124
CustodyState::Unlocked (variant)custody.rs:159

crates/dig-wallet/src/sage/rpc.rs — 738 lines removed.

symbolold line
WalletBackend::dispatch_authrpc.rs:4649
WalletBackend::dispatch_custodyrpc.rs:4552
WalletBackend::refresh_observed_derivationsrpc.rs:4514
WalletBackend::with_auth / auth() / the auth fieldrpc.rs:730 / 736 / 602
SignGrantGuard + its Droprpc.rs:4845
the m if m.starts_with("wallet.") and "auth." dispatch armsrpc.rs:4455 / 4462
the auth + custody branches of resolve_signerrpc.rs:27892794

Elsewhere:wallet_authz::{CUSTODY_PREFIX, AUTH_PREFIX, WalletMethodClass::Custody};
sage::service's UnlockAuth construction and .with_auth(auth); crates/dig-node-service/tests/live_funds_tip_e2e.rs
and runbooks/live-funds-tip-e2e.md (their premise was importing a funded mnemonic into node custody).

KEPT, and confirmed working

  • The dig-wallet crate and its dig-node-service dependency — untouched.
  • The five light-client chain reads.control.wallet.{balance, coins, peak, syncStatus, broadcast}
    are in both CONTROL_METHODS and OWNED_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 is
    emptied or the haystack accepts arbitrary names. dispatch_owned's _ arm is unreachable!() by
    construction, 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_honestly passes unchanged.
  • sync_supervisor's watched-address source.impl PuzzleHashSource for WalletCustody,
    UnionPuzzleHashSource and followed_puzzle_hashes are unchanged. All five #2609/#2823 supervisor
    tests 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 own
    DIGOP1/DIGVK1 operator identity, which no ratification retires) and lib.rs (below).
  • The push guard.a_restarted_locked_node_still_refuses_a_bundle_over_its_non_primary_key still
    runs; 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.rs is a second, independent node-side USER custody + signing surface (the
self-origin wallet UI, SPEC §16.3), and this PR does not remove it:

  • lib.rs:369POST /api/importseed_store::encrypt_seed(&m, &req.password)seed_path()
  • lib.rs:409POST /api/unlockseed_store::decrypt_seed(...) → signing session
  • lib.rs:510POST /api/send — builds and BLS-signs a payment; broadcast:true pushes to mainnet
  • lib.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 —
2ce8002c touched only sage/auth.rs, sage/custody.rs, sage/rpc.rs, seed_store.rs,
wallet_authz.rs and meta.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 over seed_path() — so deleting
this 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 not
yet 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::sage can read a .seed file, decrypt one, derive a secret key, or build a
WalletSigner from user material. resolve_signer now returns only the bring-up-injected signer
(with_signer), which no shipped build attaches — it exists for the simulator/test path — so every
require_signer() call site reports the wallet locked. That also means the node's own auto-tip spend
cannot 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-targets located every consumer — that is how the
five sync_supervisor fixtures, the two service.rs tests, the rpc.rs custody/auth blocks and the
live-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 classify sends wallet.* to WalletMethodClass::Other, whose meaning is
    "open", so the next wallet.anything anyone adds would be unauthenticated by default. The test asserts
    the 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_existed and
    auth.a_name_that_never_existed so an implementation that enumerated only the nine old names — which
    would leave the prefix open — cannot pass it.
  • the_retirement_does_not_catch_the_control_plane_wallet_reads — the opposite error. A retirement
    written as a substring rather than a prefix match would refuse control.wallet.balance and the node
    would stop answering for money while every custody test stayed green. Its haystack is the production
    CONTROL_METHODS list, not a literal, and it fails if fewer than five reads are found.
  • build_assembles_a_served_backend — asserts 404 for wallet.status, wallet.create, auth.status
    and auth.sign_unlockthrough the production assembler. wallet.status is 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_PREFIXES rather than restating them, so
    discovery and authorization cannot drift into disagreeing about which names are retired.

Mutation proof, run (working tree restored from a file copy afterwards — never git stash in this
repo):

mutationresult
delete authorize's if class == Retired { return false }every_retired_custody_name_is_refused_with_any_token FAILED — "wallet.create: refused with Some("master-token-value")". 11 passed, 1 failed.
make classify's prefix match unreachable (if false)every_retired_custody_name_is_refused_with_any_tokenandunpaired_caller_is_denied_on_every_gated_method FAILED — "left: Other, right: Retired". 10 passed, 2 failed.
drop "control.wallet.peak" from CONTROL_METHODSthe_light_client_chain_reads_survive_the_custody_carve_out FAILED — "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 across
    40 suites.
  • The first run surfaced exactly two failures, both correct (build_assembles_a_served_backend and
    on_disk_db_and_seed_persist_across_builds drove wallet.status/wallet.create and got 404). Both
    were rebased rather than deleted.
  • Version 0.143.1 → 0.144.0. Minor, matching this repo's 0.x convention for a breaking change — the
    freeze itself was feat(custody)! at 0.143.0. origin/main was re-read at bump time and merged in
    (it had moved to 0.143.1 under the sibling dig-download lane); 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 authorization
    paragraph, the §16.2 self-custody note and the §18.12 push-guard note corrected. LF endings preserved
    (0 CR before and after).
  • docs.dig.net — separate repo, ticket filed and cross-linked: docs: node-side USER custody is removed — sweep the wallet/auth method docs docs.dig.net#79.
  • dig-chrome-extension — dropped, that version is deprecated (CLAUDE.md, user 2026-08-21).
  • SYSTEM.md — not touched (superproject-owned, already updated at 0eb2b8a). No further change
    needed: 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

MichaelTaylor3dand others added 7 commits August 23, 2026 10:08
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

Copy link
Copy Markdown
ContributorAuthor

Gate in progress — interim evidence (head 5a8cbf8)

Reproduced both fail-open mutations in an independent worktree at PR head. The guard is genuinely load-bearing, not a claim:

  • Deleting authorize's Retired early return (wallet_authz.rs:204-207) → every_retired_custody_name_is_refused_with_any_token FAILS: wallet.create: refused with Some("master-token-value").
  • Replacing classify's prefix match with if false (wallet_authz.rs:156) → that test FAILS left: Other, right: Retired, andunpaired_caller_is_denied_on_every_gated_method also FAILS.

So the fail-open this deletion could have introduced (retired names falling through to the open Other class) is closed and revert-proven in both directions.

Also verified so far: dig-wallet dep retained (dig-node-service/Cargo.toml:113), impl PuzzleHashSource for WalletCustody + UnionPuzzleHashSource + followed_puzzle_hashes intact (sync_supervisor.rs:635/671/695), seed_store.rs and autoseed.rs untouched (zero diff), and dig-wallet compiles cleanly at head — which rules out the ENOSPC truncation risk on custody.rs structurally.

Remaining: required-check assertion and the SPEC honesty ruling.

@MichaelTaylor3dMichaelTaylor3d left a comment

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

itemevidence
dig-wallet crate + dig-node-service depcrates/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 readscontrol.rs:3877-3894 pins them by name in both CONTROL_METHODS and OWNED_CONTROL_METHODS
sync_supervisor watched-address sourcesync_supervisor.rs:635impl PuzzleHashSource for WalletCustody, :671UnionPuzzleHashSource, :695followed_puzzle_hashes; wired at service.rs:232
seed_store.rs / autoseed.rszero 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's Retired early return (wallet_authz.rs:204-207) → every_retired_custody_name_is_refused_with_any_token FAILS: wallet.create: refused with Some("master-token-value").
  • classify's prefix match → if false (wallet_authz.rs:156) → that test FAILS left: Other, right: Retired, andunpaired_caller_is_denied_on_every_gated_method FAILS 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) includes wallet.a_name_that_never_existed and auth.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 next wallet.* 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 production CONTROL_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 + coverage is 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-wallet compiles at head (I built it), which a truncated or incoherent 449-line file cannot do. The file terminates properly on the restrict_permissions cfg pair.
  • No --theirs-style reversion.SPEC.md is +110/-272 and the set of numbered section headings is identical to origin/main — nothing was lost wholesale. LF preserved: zero CR bytes in SPEC.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)

  1. §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.
  2. dig-constants check — asked, both directions, clear. (a) Nothing here belongs in dig-constants: RETIRED_CUSTODY_PREFIXES is node-local authorization policy with no second implementation to match, and seed_path() is a local on-disk layout. (b) Nothing here should be consuming dig-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.

Sign up for freeto 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.

feat(custody)!: carve node-side USER custody out of dig-node (step 4 of dig_ecosystem#1701)

1 participant

@MichaelTaylor3d