Skip to content

feat(wallet): node-custodied wallet + paired-token authz + sign/broadcast on behalf (#370, #371)#22

Merged
MichaelTaylor3d merged 4 commits into
mainfrom
feat/thin-client-custody-signing
Jul 11, 2026
Merged

feat(wallet): node-custodied wallet + paired-token authz + sign/broadcast on behalf (#370, #371)#22
MichaelTaylor3d merged 4 commits into
mainfrom
feat/thin-client-custody-signing

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Closes #370, #371

Node side of the thin-client epic (#365): the node becomes the wallet-key CUSTODIAN and the SIGNER/BROADCASTER for a paired extension caller. Spec-first (SPEC §7.12/§18.20/§18.21), then implemented + tested.

What changed

#370 — node-custodied provisioning + custody lifecycle + paired-token authz

  • dig-wallet sage::custody::WalletCustody — create / import / restore / unlock / lock / status / delete + node-local reveal_mnemonic (backup). Seed encrypted at rest via dig-keystore (seed_store, no hand-rolled crypto). unlock derives + loads the in-memory WalletSigner over HD indices 0..N (the runtime signer load); lock drops it.
  • dig-node-service wallet_authz — pure §7.12 policy (classify custody/mutation/other + authorize against master-or-paired token), wired into POST /: an unauthorized custody/mutation call is -32030 UNAUTHORIZED and is never relayed upstream.

#371 — sign + broadcast on behalf + per-op consent

  • sage::spend::ChiaQueryBroadcaster — the production Broadcaster (chia_query::push_tx = decentralized peers + coinset fallback, IPv6-first), fail-closed on a non-success mempool status.
  • sage::spend::ConsentBroadcaster + BroadcastConsent — one-shot per-op consent gate: forwards to the real broadcaster ONLY when consent is armed, then disarms; unconsented → fail-closed, inner broadcaster never called (nothing spent). Pre-broadcast dig-clvm validation stays fail-closed.

Trust boundary (custody of mainnet-spending keys)

  • Key never leaves the node. No lifecycle op returns the mnemonic (create returns only the receive address). The ONLY seed egress is node-local, password-gated reveal_mnemonic (self-origin backup UI / CLI) — never a wallet.*/control.* method, never over the paired boundary.
  • Encrypted at rest (Argon2id + AES-256-GCM via dig-keystore); never logged.
  • Loopback + paired-token authorized. Every wallet mutation + every wallet.* custody method requires the master control token OR a valid paired token (#280); unauthorized (no/wrong/revoked) → -32030.
  • Broadcast needs consent. A signed bundle reaches mainnet only with BOTH a paired token AND explicit per-op consent; validation is fail-closed before broadcast. CI never broadcasts to mainnet (simulator / MockBroadcaster only).

Wire contract (for SYSTEM.md — served on the authorized surface by #368/#369)

Custody (JSON-RPC, X-Dig-Control-Token = master or paired token required):

  • wallet.create { password } -> { address } (mnemonic never returned)
  • wallet.import { mnemonic, password } -> { address } · wallet.restore { mnemonic, password } -> { address }
  • wallet.unlock { password } -> { address } · wallet.lock {} -> {}
  • wallet.status {} -> { state: "none"|"locked"|"unlocked", address? } · wallet.delete { password } -> {}
    Authz: gated = all wallet.* + the mutation set (send/spend/offer/mint/transfer + sign_coin_spends/submit_transaction + state-changing actions); open = get_*/view_*/login. Sign/broadcast reuses the Sage spend shapes (§18.9) with the custodied signer + per-op consent.

Scope note

The custody + authz + sign/broadcast capabilities + contract land here on the existing authorized loopback surface. The bidirectional WS transport (#369) and the full dual-transport served wallet surface (#368) remain their own tickets; an authorized custody/mutation call returns a catalogued "not served on this transport yet" until #368/#369 wire it. dig-wallet's engine + custody are fully unit/simulator-tested and ready to serve.

Verification (local; CI on this PR)

  • cargo fmt --all -- --check clean · cargo clippy --workspace --all-targets --locked -- -D warnings clean (exit 0)
  • cargo test -p dig-wallet = 213 passed · cargo test -p dig-node-service = 92 passed
  • SemVer: feat -> MINOR. Root workspace 0.17.0 -> 0.18.0; dig-wallet 0.6.0 -> 0.7.0.

Unauthorized callers rejected — proving tests:

  • wallet_authz::tests::unpaired_caller_is_denied_on_every_gated_method (no/wrong/revoked token denied on every mutation + custody method)
  • wallet_authz::tests::master_or_paired_token_authorizes_a_gated_mutation
  • wallet_authz::tests::custody_methods_are_gated · spend_sign_and_offer_methods_are_gated_mutations · reads_and_non_wallet_methods_are_not_gated
  • sage::rpc::tests::send_xch_broadcasts_only_with_per_op_consent (unconsented broadcast fails closed, nothing spent)
  • sage::spend::tests::consent_broadcaster_refuses_without_consent_and_forwards_once_when_armed · tampered_bundle_fails_dig_clvm_validation
  • sage::custody::tests::create_persists_an_encrypted_seed_and_never_returns_the_mnemonic · wrong_password_fails_closed_on_unlock · unlock_loads_the_signer_and_lock_clears_it

🤖 Generated with Claude Code

#[test]
fn create_persists_an_encrypted_seed_and_never_returns_the_mnemonic() {
let (c, path) = fresh();
let address = c.create("hunter2pw").unwrap();
// The seed file exists and is ENCRYPTED at rest — its bytes are a dig-keystore container,
// and decrypting yields a 24-word phrase that is NOT present in plaintext on disk.
let on_disk = std::fs::read(&path).unwrap();
let recovered = seed_store::decrypt_seed(&on_disk, "hunter2pw").unwrap();
#[test]
fn import_round_trips_the_golden_seed_and_unlock_recovers_the_same_address() {
let (c, _p) = fresh();
let addr_import = c.import(ABANDON, "correcthorse").unwrap();
Comment thread crates/dig-wallet/src/sage/custody.rs Fixed
#[test]
fn unlock_loads_the_signer_and_lock_clears_it() {
let (c, _p) = fresh();
c.import(ABANDON, "correcthorse").unwrap();
#[test]
fn delete_requires_the_correct_password_then_removes_the_seed() {
let (c, path) = fresh();
c.create("rightpassword").unwrap();
assert!(path.exists(), "a wrong-password delete must preserve the seed");

// Correct password: seed removed, back to `none`.
c.delete("rightpassword").unwrap();
#[test]
fn reveal_mnemonic_returns_the_phrase_only_with_the_right_password() {
let (c, _p) = fresh();
c.import(ABANDON, "correcthorse").unwrap();
fn reveal_mnemonic_returns_the_phrase_only_with_the_right_password() {
let (c, _p) = fresh();
c.import(ABANDON, "correcthorse").unwrap();
let revealed = c.reveal_mnemonic("correcthorse").unwrap();
Comment thread crates/dig-wallet/src/sage/custody.rs Fixed
@MichaelTaylor3d MichaelTaylor3d marked this pull request as ready for review July 11, 2026 20:39
// Lock, then unlock: the same on-disk seed re-derives the identical address.
c.lock();
assert_eq!(c.status().state, CustodyState::Locked);
let addr_unlock = c.unlock("correcthorse").unwrap();
fn invalid_mnemonic_is_rejected_on_import() {
let (c, _p) = fresh();
let err = c
.import("not a valid bip39 phrase at all", "correcthorse")
MichaelTaylor3d and others added 4 commits July 11, 2026 13:42
…act (#370, #371)

Spec-first anchor for the node side of the thin-client epic (#365):
- §7.12 — paired-token authorization for wallet mutation + custody methods
  over the authorized loopback surface (-32030 for unauthorized callers).
- §18.20 — node-custodied wallet provisioning + custody lifecycle
  (create/import/restore/unlock/lock/status/delete; node-local backup only).
- §18.21 — sign + broadcast on behalf of the paired caller via the custodied
  signer + a real ChiaQuery broadcaster + per-op consent gate (fail-closed).
- §18.10 amended: paired-extension custody path supersedes "extension
  self-custodies" for that path.

Bump workspace 0.17.0 -> 0.18.0 and dig-wallet 0.6.0 -> 0.7.0 (feat, minor).

Co-Authored-By: Claude <noreply@anthropic.com>
Add crate::sage::custody::WalletCustody — the node-custodied seed lifecycle:
create/import/restore/unlock/lock/status/delete, plus node-local reveal_mnemonic
for backup. The seed is encrypted at rest via dig-keystore (seed_store); no
lifecycle op returns the mnemonic (create returns only the receive address), and
reveal is the sole seed egress (node-local, never over the paired boundary).
unlock derives + loads the in-memory WalletSigner over HD indices 0..N (the
runtime signer load); lock drops it. Wrong password fails closed.

Tests (11): status none; create persists an encrypted seed + never returns the
mnemonic; import round-trips the golden seed + unlock recovers the same address;
unlock loads the signer / lock clears it; wrong-password fails closed;
create/import refuse when a wallet exists; delete requires the password;
reveal_mnemonic only with the right password; weak password + invalid mnemonic
rejected.

Co-Authored-By: Claude <noreply@anthropic.com>
Add the sign+broadcast-on-behalf primitives (§18.21):
- ChiaQueryBroadcaster — the production Broadcaster, pushing a signed bundle to
  mainnet via chia_query::push_tx (decentralized peers + coinset fallback,
  IPv6-first), fail-closed on a non-success mempool status. to_query_bundle does
  the chia_protocol -> chia_query wire conversion (unit-tested).
- BroadcastConsent + ConsentBroadcaster — a one-shot per-op consent gate: forwards
  to the inner broadcaster ONLY when consent is armed, then disarms; an unconsented
  broadcast fails closed and the inner broadcaster is never called (nothing spent).

Tests: consent gate refuses-without / forwards-once-when-armed; to_query_bundle
hex-encodes every field; a tampered/over-spending bundle fails dig-clvm validation
(fail-closed); send_xch through dispatch broadcasts ONLY with per-op consent armed.

Co-Authored-By: Claude <noreply@anthropic.com>
Add crate::wallet_authz — the pure §7.12 policy: classify a method as custody
(wallet.*) | mutation (sign/spend/offer/mint/transfer + state-changing actions) |
other, and decide allow/deny against the master control token OR a valid paired
token (#280). Wire it into the POST / handler: a custody/mutation wallet method
requires authorization (unauthorized -> -32030 UNAUTHORIZED) and is NEVER relayed
upstream (a signing/custody request must not leave the loopback node); an
authorized call is served locally once the wallet surface is wired on this
transport (#368/#369).

Tests (6, pure): custody + mutation methods gated; reads/non-wallet open; an
unpaired caller (no/wrong/revoked token) denied on EVERY gated method; master or
paired token authorizes a gated mutation; reads authorized without a token.

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d MichaelTaylor3d force-pushed the feat/thin-client-custody-signing branch from 62ef449 to 4138dbf Compare July 11, 2026 20:42
@MichaelTaylor3d MichaelTaylor3d merged commit a66221a into main Jul 11, 2026
8 of 9 checks passed
@MichaelTaylor3d MichaelTaylor3d deleted the feat/thin-client-custody-signing branch July 11, 2026 20:55
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.

2 participants