From 214bc99889aa3018b20b8d98283d7316acf0b34f Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sat, 11 Jul 2026 19:16:27 -0700 Subject: [PATCH] feat(node): serve Sage-parity wallet RPC + bidirectional WS wallet/control transport (#368, #369) Serve the wallet RPC on 9778 (browser HTTP/CORS) + mTLS 9257; add the bidirectional /ws wallet+control transport with first-class sync-status push, paired-token authz, runtime custody signer load, and a production WalletService assembler. v0.20.0. Co-Authored-By: Claude --- Cargo.lock | 3 +- Cargo.toml | 2 +- SPEC.md | 80 +++- crates/dig-node-service/Cargo.toml | 7 + crates/dig-node-service/src/server.rs | 390 +++++++++++++++++- .../dig-node-service/tests/content_serve.rs | 2 +- crates/dig-node-service/tests/server.rs | 245 ++++++++++- crates/dig-wallet/src/sage/events.rs | 29 ++ crates/dig-wallet/src/sage/fallback.rs | 43 ++ crates/dig-wallet/src/sage/mod.rs | 1 + crates/dig-wallet/src/sage/rpc.rs | 283 ++++++++++++- crates/dig-wallet/src/sage/service.rs | 167 ++++++++ 12 files changed, 1228 insertions(+), 24 deletions(-) create mode 100644 crates/dig-wallet/src/sage/service.rs diff --git a/Cargo.lock b/Cargo.lock index a12fd6f..4494deb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1944,12 +1944,13 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.19.0" +version = "0.20.0" dependencies = [ "axum", "base64", "clap", "dig-node-core", + "dig-wallet", "digstore-core", "digstore-crypto", "digstore-stage", diff --git a/Cargo.toml b/Cargo.toml index 7f236a5..17b5328 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # (`[workspace.package].version`), so it MUST be set here for a release to fire # (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) keep their own # independent versions — only the released binary tracks the workspace version. -version = "0.19.0" +version = "0.20.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index ff501d9..29393cf 100644 --- a/SPEC.md +++ b/SPEC.md @@ -297,6 +297,9 @@ method/header-allow behavior above. | `/openrpc.json` | GET | The OpenRPC document (§6.3). | | `/.well-known/dig-node.json` | GET | The discovery document (§6.4). | | `/ws/status` | GET (WS upgrade) | WebSocket status/liveness channel (§4.5). | +| `/ws` | GET (WS upgrade) | Bidirectional wallet+control transport — correlated request/response + proactive push (§4.8). | +| `/{method}` | POST | Served Sage-parity wallet RPC (`POST {base}/{method}`, §18.1/§18.19). | +| `/{seg}` | GET | Root-absolute subresource rerooted via `Referer` into its store (§4.6). | | `/s/[:]/` | GET | Local plaintext content-serve — server-side decrypt (§4.6). | | `/verify/[:]` | GET | Verification-ledger snapshot for a page session (§4.7). | | *(fallback)* | GET | Root-absolute subresource rerooted via `Referer` into its store (§4.6). | @@ -494,6 +497,49 @@ session is returned (a page has one active root). A malformed path is `404`; any at that level). It is a DISPLAY value only — re-verification never consults it — and is exact for a leaf whose path has no odd-carry level. +### 4.8. `GET /ws` — bidirectional wallet+control transport (#369) + +A thin client (the DIG Chrome extension) drives ALL wallet reads + `control.*`/wallet mutations over +ONE upgraded WebSocket instead of per-call HTTP, and the node PROACTIVELY PUSHES sync-status +transitions + sync events on the same socket — subsuming the SSE `SyncEvent` stream (§18.14) and +`get_sync_status` polling. This is the wallet+control channel ONLY; the resolver/content transport +(§4.6, JSON-RPC §5) is UNCHANGED. + +**Origin validation (CSWSH).** Identical to §4.5: the `Origin` header is checked against the local-origin +allowlist (`chrome-extension://*` + allowed local `http://`); a disallowed browser `Origin` is rejected +`403` before the upgrade. No `Origin` (a non-browser client) is allowed (loopback bind is the defense). + +**Frames are JSON text frames.** Client→node frames carry a discriminated `type`: + +- **`request`** — `{ "type":"request", "id": , "method": , "params": , + "token": }`. `id` correlates the response. `method` is any served wallet method (Sage + snake_case + the `wallet.*` custody lifecycle, §18.20) or a `control.*`/`pairing.*` method. `params` is + the method's request object (the Sage body for a wallet method). `token` is the paired/control token + (§7.11/§7.12), required for gated ops (below). + +Node→client frames: + +- **`response`** — `{ "type":"response", "id": , "ok": , "result": ?, "error": { + "code": , "message": }? }`. `ok:true` carries `result`; `ok:false` carries `error`. +- **`sync_status`** (PUSH) — `{ "type":"sync_status", "state": "syncing"|"synced"|"disconnected", + "peak_height": ?, "target_height": ? }`. Pushed ONCE immediately on connect (the initial + snapshot) and again on every TRANSITION. `state` is derived from the wallet DB's synced peak + + initial-catch-up flag; a `stop` sync event pushes `disconnected`. The client renders "Syncing… + (peak/target)" and gates trust in balances/spends on `synced`. +- **`event`** (PUSH) — `{ "type":"event", "event": }`, where `` is the tagged-union + wire shape of §18.14 (`{"type":"coin_state"}`, `{"type":"stop"}`, …). Every published sync event is + forwarded to each connected socket (best-effort; a lagging subscriber skips the gap). + +**Subscription model.** A connected socket is IMPLICITLY subscribed to `sync_status` + `event` pushes for +its lifetime — the client gets one socket, no explicit subscribe call. A transport ping every ~5s with a +pong-timeout closes a half-open socket (as §4.5). + +**Authorization (§7.12).** Over `/ws`, wallet READS are open to the local client; every wallet MUTATION, +every `wallet.*` custody method, and every `control.*` method REQUIRES the frame's `token` to be the +master control token OR a valid paired token (pairing-admin `control.*` needs the master token). An +unauthorized request gets an `ok:false` response with an `unauthorized` error — the op never runs and is +never relayed upstream. `pairing.request`/`pairing.poll` are open (the bootstrap, §7.11). + --- ## 5. JSON-RPC surface (read plane) @@ -1434,6 +1480,14 @@ byte-identical by construction: - **Plain-HTTP + CORS** (browser mirror). A browser/MV3 extension cannot present a client cert, so the identical surface is served over the loopback plain-HTTP transport with permissive CORS. Loopback only. +On the shipped `dig-node` binary this surface IS served (#368): the service bring-up assembles ONE live +`WalletBackend` (`sage::service::WalletService` — the wallet DB + a graceful fallback tier + a shared +`EventBus` + the node custody) and (a) integrates the browser mirror onto the SAME loopback service router +as `POST /{method}` on the default port `9778` — the exact base the extension's `node-wallet` client +targets — with the wallet authz gate (§7.12) applied, and (b) brings up the mTLS `9257` sibling listener +(best-effort, non-fatal) for node-class/Sage-drop-in parity. The bidirectional `/ws` transport (§4.8) also +dispatches to this same backend. Wallet methods are NEVER relayed to the upstream gateway. + 18.2. **Request/response model.** Every endpoint is `POST /{endpoint}` where `{endpoint}` is the exact snake_case method name. There is NO JSON-RPC envelope, NO batching — the path IS the method. Request body = the method's request struct as a single JSON object (an empty body is treated as `{}`). Success → @@ -1571,8 +1625,11 @@ TAIL and surfaces in `get_cats` (this is how `$DIG` resolves from the node). 18.12. **Deferred to follow-on units.** The off-chain NFT data-blob/CHIP-0015 metadata fetch (`get_nft_data` returns on-chain fields; the metadata JSON surfaces when fetched), `exercise_options` (§18.15 — a documented, non-silent follow-on), and real image-derived theme content (§18.16 — this -backend stores a placeholder). The service bring-up that starts the dual-transport server and invokes -reconstruction after sync (via a peer/coinset `LineageSource`) is the remaining integration. +backend stores a placeholder). The service bring-up that STARTS the served surface is DONE (§18.1/#368); +the remaining integration is spawning the live direct-peer sync loop (§18.6) into that bring-up so the +shared `EventBus` is fed from real chain events (until then the DB stays honestly `syncing` and the +graceful `EmptyFallback` returns empty reads) and reconstruction-after-sync via a peer/coinset +`LineageSource`. 18.13. **Security.** Both listeners bind loopback only. The mTLS listener enforces the shared-cert mutual TLS. Multi-peer sync is a correctness/censorship property (never collapse to one peer). Reads tolerate @@ -1708,6 +1765,25 @@ an unconsented op builds + signs + validates but does NOT broadcast (nothing is to mainnet — tests drive the `chia-sdk-test` simulator (real consensus incl. BLS) or the recording `MockBroadcaster`; a real mainnet broadcast is a separate, explicitly-gated live pass. +18.22. **Served on the shipped node + runtime signer load + custody dispatch (#368/#369).** The +`WalletBackend` is BUILT and SERVED by the shipped `dig-node` (§18.1): the `POST /{method}` HTTP mirror on +`9778`, the mTLS `9257` sibling listener, and the bidirectional `/ws` transport (§4.8) all dispatch to the +one live backend. + +- **Runtime signer load.** The served backend resolves its signer from the node custody (§18.20) at + RUNTIME: `require_signer` returns the bring-up-injected signer if present, else the signer of the + currently-UNLOCKED custody session. A paired `wallet.unlock` therefore enables signing/spend immediately, + WITHOUT reconstructing the backend; `wallet.lock`/`delete` disable it again. (The test/simulator path + still injects a fixed signer via `with_signer`, which wins when present.) +- **Custody lifecycle dispatch.** The `wallet.*` methods (`wallet.status`/`create`/`import`/`restore`/ + `unlock`/`lock`/`delete`, §18.20) are dispatched by `WalletBackend::dispatch` to the attached + `WalletCustody`. `wallet.create`/`import`/`restore`/`unlock` return `{ "address": "xch1…" }`; + `wallet.status` returns the custody status (`{ "state": "none"|"locked"|"unlocked", "address"? }`); + `wallet.lock`/`delete` return the resulting state. All are gated (§7.12). +- **Sync-status snapshot.** `WalletBackend::sync_status()` derives the `{ state, peak_height, target_height }` + tri-state (`SyncStatus`, `crate::sage::events`) from the wallet DB — `synced` iff the initial catch-up + completed, else `syncing`; it is the body the `/ws` transport pushes (§4.8) and re-pushes on transition. + ## 19. Peer network — NAT traversal, discovery, address book, and content location The standalone `dig-node` binary runs an L7 peer network (the in-process FFI/browser host does not — diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index 11d5b3e..54d4b08 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -35,6 +35,13 @@ path = "src/lib.rs" # around it, and produces the `dig-node` binary (distinct name from the engine lib). dig-node-core = { path = "../dig-node-core" } +# The Sage-parity wallet engine (crate `dig_wallet`) — the node-custodied wallet DB + dual-transport +# dispatch + seed custody. This shell WIRES it into bring-up (#368): it builds one live +# `WalletBackend` (`sage::service::WalletService`) and serves the Sage method surface on the +# extension-reachable loopback (`POST /{method}`, 9778) + the mTLS 9257 listener, and bridges the +# bidirectional WS wallet+control transport (#369) onto it. +dig-wallet = { path = "../dig-wallet" } + # HTTP stack: the same axum/tokio the node itself uses, so there is one async runtime # and one server framework across the node and the service shell. `ws` enables # `axum::extract::ws` for the `GET /ws/status` liveness endpoint (#239). diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index b3a087b..0e7b63c 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use std::time::Instant; use axum::{ - body::Body, + body::{Body, Bytes}, extract::{ ws::{Message, WebSocket, WebSocketUpgrade}, Path, Request, State, @@ -20,11 +20,15 @@ use axum::{ http::{header, HeaderMap, HeaderValue, Method, StatusCode, Uri}, middleware::{self, Next}, response::{IntoResponse, Response}, - routing::get, + routing::{get, post}, Json, Router, }; use dig_node_core::content_serve::{PlaintextOutcome, ServeSource}; use dig_node_core::{cache_cap_bytes, cache_used_bytes, handle_rpc, Node}; +use dig_wallet::sage::events::{SyncEvent, SyncLifecycle, SyncStatus}; +use dig_wallet::sage::rpc::WalletBackend; +use dig_wallet::sage::service::WalletService; +use dig_wallet::sage::transport::{serve_mtls, SharedCert, DEFAULT_MTLS_PORT}; use serde_json::{json, Value}; use tower_http::cors::{AllowOrigin, CorsLayer}; @@ -73,6 +77,14 @@ pub struct AppState { /// OPEN `pairing.request`/`pairing.poll` and the gated `control.pairing.*` /// handlers see one consistent set of in-flight pairings. pairings: Arc>, + /// The SERVED Sage-parity wallet backend (#368): one live [`WalletBackend`] (wallet DB + + /// fallback tier + shared [`EventBus`] + node custody) dispatched by BOTH the loopback + /// `POST /{method}` HTTP mirror the extension targets AND the bidirectional `/ws` wallet+control + /// transport (#369). Custody-backed, so a paired `wallet.unlock` enables signing at runtime. + wallet: Arc, + /// The shared self-signed cert the mTLS `9257` listener presents (Sage byte-parity, node-class + /// clients). Held so [`serve_with_shutdown`] can bring up that sibling listener. + wallet_cert: SharedCert, } /// dig-node's "method not found" error code. `handle_rpc` resolves only @@ -129,6 +141,18 @@ pub fn router(state: AppState) -> Router { // client's SW — the OPEN SOCKET is itself the liveness signal, with a // heartbeat detecting a half-open connection. See [`ws_status`]. .route("/ws/status", get(ws_status)) + // `GET /ws` (#369): the BIDIRECTIONAL wallet+control transport. A thin client drives every + // wallet read + `control.*`/wallet mutation over this ONE socket (correlated request → + // response), and the node PUSHES sync-status transitions + sync events proactively — + // subsuming the SSE stream + per-call HTTP polling. Paired-token gated for mutations + + // `control.*` (§7.12); reads open. See [`ws_wallet`]. Resolver/content transport untouched. + .route("/ws", get(ws_wallet)) + // `POST /{method}` (#368): the Sage-parity wallet RPC surface the extension's `node-wallet` + // client targets (`POST {base}/{method}`, snake_case Sage body). Served by the live + // node-custodied [`WalletBackend`]; mutations + `wallet.*` are paired-token gated and NEVER + // relayed upstream. A one-segment GET (a root-absolute store subresource) still reaches the + // content-serve path via the method-router `.get` arm, so this never shadows content serving. + .route("/:method", post(wallet_rpc).get(fallback_serve)) // `GET /s/[:]/` (#289): the LOCAL plaintext content-serve // surface — the node decrypts server-side and returns the real website over // loopback, DISTINCT from the blind-ciphertext JSON-RPC `POST /` above. See @@ -202,10 +226,14 @@ async fn host_guard(req: Request, next: Next) -> Response { /// Construct the shared state from config: apply the upstream to dig-node's env, /// then build the node from the environment (cache dir/cap, §21 identity), and /// generate/load the local control token into the node's config dir. -pub fn build_state(config: &Config) -> AppState { +pub async fn build_state(config: &Config) -> AppState { config.apply_to_env(); let node = Node::from_env(); let config_path = dig_node_core::config_path(); + // Assemble the SERVED wallet under the node config dir (#368): the live wallet DB + custody + + // shared event bus + mTLS cert. Never blocks on network (graceful fallback tier). + let config_dir = config_path.parent().unwrap_or(&config_path).to_path_buf(); + let wallet_service = WalletService::build(&config_dir).await; // Generate (or read) the control token into /control-token. A // failure to persist it (e.g. unwritable dir) is non-fatal: fall back to an // in-memory token so the control plane is still gated (a controller that can't @@ -240,6 +268,17 @@ pub fn build_state(config: &Config) -> AppState { pairings: Arc::new(std::sync::Mutex::new( crate::pairing::PendingPairings::default(), )), + wallet: wallet_service.backend, + wallet_cert: wallet_service.cert, + } +} + +impl AppState { + /// The served node-custodied wallet backend (#368). Exposed so a caller that built the state + /// (e.g. an integration test, or the bring-up that spawns the mTLS listener) can share the SAME + /// backend + its event bus the router dispatches to. + pub fn wallet_backend(&self) -> Arc { + self.wallet.clone() } } @@ -561,13 +600,14 @@ async fn rpc( )), ); } + // Authorized: serve via the node-custodied wallet backend (#368) — the JSON-RPC `params` + // object IS the Sage request body. A signing/custody request is NEVER relayed upstream. + let params = req.get("params").cloned().unwrap_or(json!({})); + let body = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()); + let (status, out) = state.wallet.dispatch(&method, &body).await; return ( StatusCode::OK, - Json(rpc_error( - id, - ErrorCode::MethodNotFound, - "the node-custodied wallet surface is not served on this transport yet", - )), + Json(wallet_result_to_jsonrpc(id, status, out)), ); } @@ -625,6 +665,307 @@ async fn proxy(http: &reqwest::Client, upstream: &str, req: &Value) -> Result Value { + if status == 200 { + let result: Value = serde_json::from_str(&body).unwrap_or(Value::Null); + json!({ "jsonrpc": "2.0", "id": id, "result": result }) + } else { + let code = match status { + 400 => ErrorCode::InvalidParams, + 401 => ErrorCode::Unauthorized, + 404 => ErrorCode::MethodNotFound, + _ => ErrorCode::DispatchFailed, + }; + rpc_error(id, code, body) + } +} + +/// The token a wallet/control caller presented on the loopback surface: the `X-Dig-Control-Token` +/// header, else a `_control_token` field in the (Sage/JSON) body. +fn presented_wallet_token(headers: &HeaderMap, body: &str) -> Option { + if let Some(t) = headers + .get(control::CONTROL_TOKEN_HEADER) + .and_then(|v| v.to_str().ok()) + { + if !t.is_empty() { + return Some(t.to_string()); + } + } + serde_json::from_str::(body).ok().and_then(|v| { + v.get("_control_token") + .and_then(|t| t.as_str()) + .map(String::from) + }) +} + +/// Whether a wallet-surface caller presenting `token` is authorized for `method` (§7.12): reads are +/// open; every custody-lifecycle + mutation method needs the master control token OR a paired token. +fn wallet_call_authorized(state: &AppState, method: &str, token: Option<&str>) -> bool { + let paired_path = pairing::paired_tokens_path(&state.config_path); + wallet_authz::authorize(method, token, &state.control_token, |t| { + pairing::is_paired_token(&paired_path, t) + }) +} + +/// `POST /{method}` (#368) — the Sage-parity wallet RPC surface. Dispatches to the node-custodied +/// [`WalletBackend`], reproducing Sage's response model: `200` + JSON on success, or the mapped +/// status with a plain-text message on error. Custody + mutation methods are paired-token gated +/// (§7.12) and are never relayed upstream; wallet reads are open to local consumers. +async fn wallet_rpc( + State(state): State, + Path(method): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + let body_str = String::from_utf8_lossy(&body).into_owned(); + if wallet_authz::requires_authorization(&method) { + let token = presented_wallet_token(&headers, &body_str); + if !wallet_call_authorized(&state, &method, token.as_deref()) { + return ( + StatusCode::UNAUTHORIZED, + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + format!( + "401: {method} requires the local control token (X-Dig-Control-Token header) \ + or a paired controller token (see `dig-node pair`)" + ), + ) + .into_response(); + } + } + let (status, out) = state.wallet.dispatch(&method, &body_str).await; + let code = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let content_type = if status == 200 { + "application/json" + } else { + "text/plain; charset=utf-8" + }; + (code, [(header::CONTENT_TYPE, content_type)], out).into_response() +} + +/// `GET /ws` (#369) — upgrade to the bidirectional wallet+control WebSocket. Same CSWSH `Origin` +/// allowlist as `/ws/status`: a disallowed browser Origin is rejected server-side (a WS handshake +/// is not gated by CORS); a request with NO Origin (a non-browser client) is allowed. +async fn ws_wallet( + State(state): State, + headers: HeaderMap, + ws: WebSocketUpgrade, +) -> Response { + if let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) { + if !is_local_origin(origin) { + return ( + StatusCode::FORBIDDEN, + Json(rpc_error( + Value::Null, + ErrorCode::InvalidRequest, + "dig-node: Origin not allowed for /ws", + )), + ) + .into_response(); + } + } + ws.on_upgrade(move |socket| ws_wallet_session(socket, state)) +} + +/// Build the `sync_status` PUSH frame from a [`SyncStatus`] (adds the `type` tag). +fn status_push_frame(status: &SyncStatus) -> Value { + let mut v = serde_json::to_value(status).unwrap_or_else(|_| json!({})); + if let Some(obj) = v.as_object_mut() { + obj.insert("type".into(), json!("sync_status")); + } + v +} + +/// A WS error response frame (correlated by `id`). +fn ws_err(id: Value, code: ErrorCode, msg: &str) -> Value { + json!({ "id": id, "type": "response", "ok": false, "error": { "code": code.code(), "message": msg } }) +} + +/// Normalize a `control.*`/`pairing.*` JSON-RPC envelope into the uniform WS response frame. +fn ws_from_jsonrpc(id: Value, env: Value) -> Value { + if let Some(err) = env.get("error") { + json!({ "id": id, "type": "response", "ok": false, "error": err.clone() }) + } else { + json!({ "id": id, "type": "response", "ok": true, "result": env.get("result").cloned().unwrap_or(Value::Null) }) + } +} + +/// Dispatch ONE correlated WS request → the uniform response frame. Routes `control.*` (token-gated, +/// pairing-admin needs the master token), the OPEN `pairing.request`/`pairing.poll`, and the wallet +/// surface (reads open; custody + mutations paired-token gated, §7.12). Wallet/custody ops are +/// served by the node-custodied backend and never relayed upstream. +async fn ws_dispatch( + state: &AppState, + id: Value, + method: &str, + params: Value, + token: Option<&str>, +) -> Value { + if control::is_control_method(method) { + let master_ok = control::is_authorized(method, token, &state.control_token); + let paired_ok = !control::is_pairing_admin_method(method) + && token.is_some_and(|t| { + pairing::is_paired_token(&pairing::paired_tokens_path(&state.config_path), t) + }); + if !(master_ok || paired_ok) { + return ws_err( + id, + ErrorCode::Unauthorized, + "control.* requires the local control token or a paired controller token", + ); + } + let ctx = control_ctx(state); + let env = control::dispatch_control(&ctx, id.clone(), method, ¶ms).await; + return ws_from_jsonrpc(id, env); + } + if method == "pairing.request" || method == "pairing.poll" { + let env = if method == "pairing.request" { + pairing::request(&state.pairings, id.clone(), ¶ms) + } else { + pairing::poll(&state.pairings, id.clone(), ¶ms) + }; + return ws_from_jsonrpc(id, env); + } + if wallet_authz::requires_authorization(method) && !wallet_call_authorized(state, method, token) + { + return ws_err( + id, + ErrorCode::Unauthorized, + "this wallet method requires a paired controller token (see `dig-node pair`)", + ); + } + let body = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()); + let (status, out) = state.wallet.dispatch(method, &body).await; + if status == 200 { + let result: Value = serde_json::from_str(&out).unwrap_or(Value::Null); + json!({ "id": id, "type": "response", "ok": true, "result": result }) + } else { + json!({ "id": id, "type": "response", "ok": false, "error": { "code": status, "message": out } }) + } +} + +/// Parse one client text frame and, if it is a `request`, dispatch it to a response frame. Non-request +/// frames (client-side keepalives, unknown types) are ignored (`None`). +async fn ws_handle_text(state: &AppState, txt: &str) -> Option { + let v: Value = serde_json::from_str(txt).ok()?; + let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("request"); + if ty != "request" { + return None; + } + let id = v.get("id").cloned().unwrap_or(Value::Null); + let method = v.get("method").and_then(|m| m.as_str()).unwrap_or(""); + if method.is_empty() { + return Some(ws_err(id, ErrorCode::InvalidRequest, "missing method")); + } + let params = v.get("params").cloned().unwrap_or_else(|| json!({})); + let token = v.get("token").and_then(|t| t.as_str()); + Some(ws_dispatch(state, id, method, params, token).await) +} + +/// Drive one `/ws` connection (#369). On connect the client is subscribed to the node's push +/// stream: the current `sync_status` snapshot is pushed immediately, then every sync event is +/// forwarded (`{type:"event",...}`) and any resulting sync-status transition is pushed +/// (`{type:"sync_status",...}`) — a `SyncEvent::Stop` pushes `disconnected`. Client `request` +/// frames are dispatched to correlated `response` frames. A transport heartbeat + pong-timeout +/// closes a half-open socket. +async fn ws_wallet_session(mut socket: WebSocket, state: AppState) { + use tokio::sync::broadcast::error::RecvError; + + let mut rx = state.wallet.events().subscribe(); + let mut bus_open = true; + + // Initial sync-status snapshot so the client can render syncing/synced immediately. + let mut last: Option = state.wallet.sync_status().await.ok(); + if let Some(s) = &last { + if socket + .send(Message::Text(status_push_frame(s).to_string())) + .await + .is_err() + { + return; + } + } + + let mut ticker = tokio::time::interval(WS_HEARTBEAT_INTERVAL); + ticker.tick().await; + let mut last_seen = tokio::time::Instant::now(); + + loop { + tokio::select! { + _ = ticker.tick() => { + if last_seen.elapsed() > WS_PONG_TIMEOUT { + let _ = socket.send(Message::Close(None)).await; + return; + } + if socket.send(Message::Ping(Vec::new())).await.is_err() { + return; + } + } + ev = rx.recv(), if bus_open => { + match ev { + Ok(event) => { + // Forward the raw sync event (subsumes the SSE stream). + let frame = json!({ "type": "event", "event": event }); + if socket.send(Message::Text(frame.to_string())).await.is_err() { + return; + } + // Recompute the tri-state and push on transition; Stop ⇒ disconnected. + let cur = if matches!(event, SyncEvent::Stop) { + SyncStatus { + state: SyncLifecycle::Disconnected, + peak_height: last.as_ref().and_then(|s| s.peak_height), + target_height: last.as_ref().and_then(|s| s.target_height), + } + } else { + state.wallet.sync_status().await.unwrap_or(SyncStatus { + state: SyncLifecycle::Syncing, + peak_height: None, + target_height: None, + }) + }; + if last.as_ref() != Some(&cur) { + if socket + .send(Message::Text(status_push_frame(&cur).to_string())) + .await + .is_err() + { + return; + } + last = Some(cur); + } + } + // A lagging subscriber skips the gap; a closed bus stops the push arm but the + // request/response side keeps serving. + Err(RecvError::Lagged(_)) => {} + Err(RecvError::Closed) => { bus_open = false; } + } + } + msg = socket.recv() => { + match msg { + Some(Ok(Message::Text(txt))) => { + last_seen = tokio::time::Instant::now(); + if let Some(frame) = ws_handle_text(&state, &txt).await { + if socket.send(Message::Text(frame.to_string())).await.is_err() { + return; + } + } + } + Some(Ok(Message::Close(_))) => { + let _ = socket.send(Message::Close(None)).await; + return; + } + Some(Ok(_)) => { last_seen = tokio::time::Instant::now(); } + Some(Err(_)) | None => return, + } + } + } + } +} + // -- Local plaintext content-serve (#289/#290) --------------------------------------------------- // // `GET /s/[:]/` decrypts server-side and returns the real website over @@ -827,7 +1168,13 @@ where F: std::future::Future + Send + 'static, { let addr = config.bind_addr(); - let state = build_state(&config); + let state = build_state(&config).await; + + // Grab the wallet backend + mTLS cert before the router consumes `state` (#368): the served + // wallet rides the loopback HTTP surface (`POST /{method}`) AND a sibling mTLS 9257 listener + // for node-class/Sage-drop-in parity (§5.3 transport). + let wallet_backend = state.wallet.clone(); + let wallet_cert = state.wallet_cert.clone(); // §14 autonomous sync (#213): bring up the L7 peer network — the connected peer // pool, the content-location DHT + P2P content engine, PEX, and the chain-watch + @@ -845,6 +1192,31 @@ where dig_node_core::peer::spawn_peer_network(state.node.clone()); } + // Best-effort mTLS `9257` listener (#368, Sage byte-parity, node-class clients, §5.3). Binds + // loopback only; a bind failure (port in use) is NON-FATAL — the wallet stays reachable over + // the plain-HTTP `POST /{method}` surface + the `/ws` transport, which is what the extension + // uses. The listener stops when the process exits with the rest of the node. + match std::net::TcpListener::bind(("127.0.0.1", DEFAULT_MTLS_PORT)) { + Ok(l) => { + let _ = l.set_nonblocking(true); + let backend = wallet_backend.clone(); + let cert = wallet_cert.clone(); + tokio::spawn(async move { + if let Err(e) = serve_mtls(backend, l, &cert).await { + eprintln!("dig-node: WARN wallet mTLS listener exited: {e}"); + } + }); + eprintln!( + "dig-node: wallet mTLS (Sage-parity) listening on 127.0.0.1:{DEFAULT_MTLS_PORT}" + ); + } + Err(e) => eprintln!( + "dig-node: WARN could not bind 127.0.0.1:{DEFAULT_MTLS_PORT} for the wallet mTLS \ + listener ({e}); node-class Sage-parity clients unavailable (non-fatal). The wallet \ + is still served on the loopback HTTP surface + /ws." + ), + } + let app = router(state); // (1) The ALWAYS-ON localhost listener (IPv4). A failure here is fatal: no endpoint. diff --git a/crates/dig-node-service/tests/content_serve.rs b/crates/dig-node-service/tests/content_serve.rs index f9f2b02..9c1b5a6 100644 --- a/crates/dig-node-service/tests/content_serve.rs +++ b/crates/dig-node-service/tests/content_serve.rs @@ -118,7 +118,7 @@ async fn start_server(upstream: &str) -> (SocketAddr, PathBuf, EnvHold) { dig_local: false, ..dig_node_service::Config::default() }; - let state = dig_node_service::server::build_state(&config); + let state = dig_node_service::server::build_state(&config).await; let app = dig_node_service::server::router(state); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index 4bcb4f7..71fba47 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -13,7 +13,9 @@ use std::sync::{Arc, Mutex, OnceLock}; use axum::routing::post; use axum::{Json, Router}; -use futures_util::StreamExt; +use dig_wallet::sage::events::SyncEvent; +use dig_wallet::sage::rpc::WalletBackend; +use futures_util::{SinkExt, StreamExt}; use serde_json::{json, Value}; /// Serializes every test that builds a companion server. dig-node reads @@ -140,7 +142,7 @@ async fn start_companion_full(upstream: &str) -> (SocketAddr, String, EnvHold) { std::fs::create_dir_all(&cache).expect("create test cache dir"); std::env::set_var("DIG_NODE_CACHE", &cache); std::env::set_var("DIG_NODE_CACHE_CAP", "67108864"); - let state = dig_node_service::server::build_state(&config); + let state = dig_node_service::server::build_state(&config).await; // The token the server wrote (read from disk, exactly as a real controller // would). config_path() resolves under the temp DIG_NODE_CACHE we just set. let token = dig_node_service::control::load_or_create_token().unwrap(); @@ -156,6 +158,40 @@ async fn start_companion_full(upstream: &str) -> (SocketAddr, String, EnvHold) { (addr, token, EnvHold(hold)) } +/// Like [`start_companion_full`] but ALSO returns the served wallet backend (#368/#369) so a WS +/// push test can drive the backend's event bus directly. Same per-call on-disk isolation + env +/// lock (the wallet DB + seed live under the same per-test config dir). +async fn start_companion_wallet( + upstream: &str, +) -> (SocketAddr, String, Arc, EnvHold) { + let config = dig_node_service::Config { + upstream: upstream.to_string(), + port: 0, + ..dig_node_service::Config::default() + }; + let hold = env_guard().lock_owned().await; + let (state, token, backend) = { + let unique = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let base = + std::env::temp_dir().join(format!("dig-node-test-{}-{}", std::process::id(), unique)); + let cache = base.join("cache"); + std::fs::create_dir_all(&cache).expect("create test cache dir"); + std::env::set_var("DIG_NODE_CACHE", &cache); + std::env::set_var("DIG_NODE_CACHE_CAP", "67108864"); + let state = dig_node_service::server::build_state(&config).await; + let token = dig_node_service::control::load_or_create_token().unwrap(); + let backend = state.wallet_backend(); + (state, token, backend) + }; + let app = dig_node_service::server::router(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (addr, token, backend, EnvHold(hold)) +} + fn client() -> reqwest::Client { reqwest::Client::new() } @@ -1473,3 +1509,208 @@ async fn ws_status_accepts_a_chrome_extension_origin() { .expect("ws error"); assert!(msg.into_text().unwrap().contains("\"type\":\"status\"")); } + +// --- #368: the served Sage-parity wallet RPC surface (POST /{method}) ---------------- +// +// The shipped node now BUILDS + SERVES the node-custodied wallet backend on the +// extension-reachable loopback. These prove the extension's `node-wallet` target +// (`POST {base}/{method}`) is answered by the installed binary (not relayed upstream), +// and that custody/mutation methods are paired-token gated. + +/// **Proves (#368):** the running node answers the core Sage reads (`get_version`, +/// `get_sync_status`, `get_coins`) over `POST /{method}` on the loopback — not a `404` +/// and not relayed to the upstream. +#[tokio::test] +async fn wallet_rpc_answers_core_reads_on_loopback() { + let (upstream, calls) = start_mock_upstream().await; + let (addr, _token, _backend, _hold) = start_companion_wallet(&upstream).await; + + for (method, body) in [ + ("get_version", "{}"), + ("get_sync_status", "{}"), + ("get_coins", r#"{"offset":0,"limit":10}"#), + ] { + let resp = client() + .post(format!("http://{addr}/{method}")) + .header("content-type", "application/json") + .body(body) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "{method} must be answered by the node"); + let v: Value = resp.json().await.unwrap(); + if method == "get_version" { + assert!(v["version"].is_string(), "get_version returns a version"); + } + } + // None of the wallet reads were relayed to the upstream DIG RPC. + assert!( + calls.lock().unwrap().is_empty(), + "wallet reads must not relay upstream" + ); +} + +/// **Proves (#368 security):** an unauthenticated wallet MUTATION (`send_xch`) over the +/// served surface is rejected `401` — a spend is never served without the paired/control +/// token, and never relayed upstream. +#[tokio::test] +async fn wallet_rpc_rejects_unauthorized_mutation() { + let (upstream, calls) = start_mock_upstream().await; + let (addr, _token, _backend, _hold) = start_companion_wallet(&upstream).await; + + let resp = client() + .post(format!("http://{addr}/send_xch")) + .header("content-type", "application/json") + .body(r#"{"address":"xch1xxxx","amount":1,"fee":0}"#) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 401, "an unauthorized spend must be 401"); + assert!( + calls.lock().unwrap().is_empty(), + "a spend must never relay upstream" + ); +} + +// --- #369: the bidirectional WS wallet+control transport (GET /ws) ------------------- + +/// Read the next TEXT frame as JSON within a timeout, skipping transport ping/pong/close. +async fn next_ws_json(ws: &mut S) -> Value +where + S: futures_util::Stream< + Item = Result< + tokio_tungstenite::tungstenite::Message, + tokio_tungstenite::tungstenite::Error, + >, + > + Unpin, +{ + loop { + let msg = tokio::time::timeout(std::time::Duration::from_secs(5), ws.next()) + .await + .expect("timed out waiting for a WS frame") + .expect("stream ended") + .expect("ws error"); + if let tokio_tungstenite::tungstenite::Message::Text(t) = msg { + return serde_json::from_str(&t).expect("frame is valid JSON"); + } + // Skip ping/pong/binary/close keepalives. + } +} + +/// **Proves (#369):** on connect `/ws` pushes the initial `sync_status`, and a correlated +/// `request` (a wallet read) gets a `response` frame echoing its `id`. +#[tokio::test] +async fn ws_wallet_pushes_status_and_correlates_a_request() { + use tokio_tungstenite::tungstenite::Message; + let (upstream, _calls) = start_mock_upstream().await; + let (addr, _token, _backend, _hold) = start_companion_wallet(&upstream).await; + + let (mut ws, _resp) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .expect("connect to /ws"); + + // Initial push: a sync_status frame (fresh DB => syncing). + let snap = next_ws_json(&mut ws).await; + assert_eq!(snap["type"], json!("sync_status")); + assert_eq!(snap["state"], json!("syncing")); + + // A correlated wallet read. + ws.send(Message::Text( + r#"{"id":"req-1","type":"request","method":"get_version","params":{}}"#.into(), + )) + .await + .unwrap(); + let resp = next_ws_json(&mut ws).await; + assert_eq!(resp["id"], json!("req-1")); + assert_eq!(resp["type"], json!("response")); + assert_eq!(resp["ok"], json!(true)); + assert!(resp["result"]["version"].is_string()); +} + +/// **Proves (#369 push):** the node forwards sync EVENTS to the subscribed socket and pushes +/// a `sync_status` transition to `disconnected` when the sync loop stops. +#[tokio::test] +async fn ws_wallet_pushes_events_and_disconnected_on_stop() { + let (upstream, _calls) = start_mock_upstream().await; + let (addr, _token, backend, _hold) = start_companion_wallet(&upstream).await; + + let (mut ws, _resp) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .expect("connect to /ws"); + + // Drain the initial sync_status snapshot (subscription is now active). + let snap = next_ws_json(&mut ws).await; + assert_eq!(snap["type"], json!("sync_status")); + + // A coin-state change is forwarded as an event frame. + backend.events().publish(SyncEvent::CoinState); + let ev = next_ws_json(&mut ws).await; + assert_eq!(ev["type"], json!("event")); + assert_eq!(ev["event"]["type"], json!("coin_state")); + + // Stop => the event is forwarded AND a disconnected sync_status transition is pushed. + backend.events().publish(SyncEvent::Stop); + let ev = next_ws_json(&mut ws).await; + assert_eq!(ev["type"], json!("event")); + assert_eq!(ev["event"]["type"], json!("stop")); + let status = next_ws_json(&mut ws).await; + assert_eq!(status["type"], json!("sync_status")); + assert_eq!(status["state"], json!("disconnected")); +} + +/// **Proves (#369 authz):** an unauthorized wallet MUTATION and an unauthorized `control.*` +/// call over the WS are both rejected (`ok:false`), while a wallet READ is served. +#[tokio::test] +async fn ws_wallet_rejects_unauthorized_mutation_and_control() { + use tokio_tungstenite::tungstenite::Message; + let (upstream, _calls) = start_mock_upstream().await; + let (addr, _token, _backend, _hold) = start_companion_wallet(&upstream).await; + + let (mut ws, _resp) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .expect("connect to /ws"); + let _ = next_ws_json(&mut ws).await; // drain initial sync_status + + // Unauthorized mutation (no token). + ws.send(Message::Text( + r#"{"id":"m1","type":"request","method":"send_xch","params":{"address":"xch1x","amount":1,"fee":0}}"#.into(), + )) + .await + .unwrap(); + let resp = next_ws_json(&mut ws).await; + assert_eq!(resp["id"], json!("m1")); + assert_eq!(resp["ok"], json!(false), "unauthorized mutation rejected"); + + // Unauthorized control.* (no token). + ws.send(Message::Text( + r#"{"id":"c1","type":"request","method":"control.status","params":{}}"#.into(), + )) + .await + .unwrap(); + let resp = next_ws_json(&mut ws).await; + assert_eq!(resp["id"], json!("c1")); + assert_eq!(resp["ok"], json!(false), "unauthorized control.* rejected"); +} + +/// **Proves (#369 CSWSH):** `/ws` validates the `Origin` with the same local-origin allowlist — +/// a disallowed browser Origin is rejected at the handshake (`403`), never upgraded. +#[tokio::test] +async fn ws_wallet_rejects_a_disallowed_origin() { + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + let (upstream, _calls) = start_mock_upstream().await; + let (addr, _token, _backend, _hold) = start_companion_wallet(&upstream).await; + + let mut request = format!("ws://{addr}/ws") + .into_client_request() + .expect("build client request"); + request + .headers_mut() + .insert("origin", "https://evil.example.com".parse().unwrap()); + + match tokio_tungstenite::connect_async(request).await { + Err(tokio_tungstenite::tungstenite::Error::Http(resp)) => { + assert_eq!(resp.status(), 403); + } + other => panic!("expected an HTTP 403 rejection, got {other:?}"), + } +} diff --git a/crates/dig-wallet/src/sage/events.rs b/crates/dig-wallet/src/sage/events.rs index b017a37..590e9de 100644 --- a/crates/dig-wallet/src/sage/events.rs +++ b/crates/dig-wallet/src/sage/events.rs @@ -49,6 +49,35 @@ pub enum SyncEvent { NftData, } +/// The wallet sync lifecycle a thin client renders + gates trust on (epic #365 §1, #369). The +/// node PUSHES a [`SyncStatus`] whenever this transitions, so the extension shows +/// "Syncing… (h/target)" and never adopts a silent synced-zero. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SyncLifecycle { + /// A peer connection is up and the initial puzzle-state catch-up has NOT completed. + Syncing, + /// The initial catch-up completed; balances/spends can be trusted. + Synced, + /// No live sync (the sync loop stopped / no peer) — the client shows a disconnected wallet. + Disconnected, +} + +/// The first-class sync-status push (#369): the tri-state plus the synced peak + best-known target +/// height. Pushed proactively to subscribed WS clients on every transition; also the body of a +/// `get_sync_status`-adjacent poll. Field omission mirrors the Sage wire convention. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyncStatus { + /// The tri-state the client renders + gates trust on. + pub state: SyncLifecycle, + /// The highest block height the wallet DB has processed (`None` before any progress). + #[serde(skip_serializing_if = "Option::is_none")] + pub peak_height: Option, + /// The height the wallet is syncing TOWARD (best-known; equals `peak_height` once synced). + #[serde(skip_serializing_if = "Option::is_none")] + pub target_height: Option, +} + /// The default channel capacity: generous enough that a slow SSE consumer does not miss a /// burst of sync events under normal operation; a consumer that falls further behind than /// this sees `RecvError::Lagged` (handled by the SSE bridge, see `sage::transport`) rather diff --git a/crates/dig-wallet/src/sage/fallback.rs b/crates/dig-wallet/src/sage/fallback.rs index a89f8a7..d7d287d 100644 --- a/crates/dig-wallet/src/sage/fallback.rs +++ b/crates/dig-wallet/src/sage/fallback.rs @@ -135,6 +135,49 @@ impl ChainFallback for CoinsetFallback { } } +/// A graceful no-network fallback (#368): every read returns empty / not-found rather than +/// erroring. It is the default fallback for the shipped node's served backend BEFORE the +/// direct-peer sync loop is wired (SPEC §18.12): a wallet-scoped read of an unsynced DB then +/// reports an honest empty result (matching the pushed `syncing` state) instead of a `500`, and +/// the node never blocks bring-up on network/TLS setup. Replaced by [`CoinsetFallback`] once the +/// live sync loop is attached. +#[derive(Debug, Default, Clone, Copy)] +pub struct EmptyFallback; + +#[async_trait] +impl ChainFallback for EmptyFallback { + async fn coin_records_by_puzzle_hashes(&self, _phs: &[String]) -> Result> { + Ok(Vec::new()) + } + async fn coin_records_by_hints(&self, _hints: &[String]) -> Result> { + Ok(Vec::new()) + } + async fn coin_record_by_id(&self, _coin_id: &str) -> Result> { + Ok(None) + } +} + +#[cfg(test)] +mod empty_fallback_tests { + use super::*; + + #[tokio::test] + async fn empty_fallback_returns_empty_never_errors() { + let fb = EmptyFallback; + assert!(fb + .coin_records_by_puzzle_hashes(&["00".repeat(32)]) + .await + .unwrap() + .is_empty()); + assert!(fb + .coin_records_by_hints(&["ab".into()]) + .await + .unwrap() + .is_empty()); + assert!(fb.coin_record_by_id("cc").await.unwrap().is_none()); + } +} + #[cfg(test)] pub(crate) mod mock { //! A deterministic in-memory [`ChainFallback`] for routing/RPC unit tests. diff --git a/crates/dig-wallet/src/sage/mod.rs b/crates/dig-wallet/src/sage/mod.rs index f93fa43..d337fe3 100644 --- a/crates/dig-wallet/src/sage/mod.rs +++ b/crates/dig-wallet/src/sage/mod.rs @@ -69,6 +69,7 @@ pub mod offers; pub mod options; pub mod routing; pub mod rpc; +pub mod service; pub mod singleton; pub mod spend; pub mod sync; diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 9b2c1bc..07a1cfc 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -21,6 +21,7 @@ use serde_json::Value; use chia_wallet_sdk::driver::Cat; +use super::custody::WalletCustody; use super::db::{CoinRow, OfferDbRow, OptionDbRow, WalletDb}; use super::events::EventBus; use super::fallback::{ChainFallback, FallbackCoin}; @@ -85,6 +86,11 @@ pub struct WalletBackend { /// configured puzzle hashes (legacy), and report "not tracking" when BOTH are empty. /// The node NEVER holds the client's private key (#217): scoping is by public data. identity: Arc>>, + /// The node-custodied seed lifecycle (#370), attached at bring-up ([`Self::with_custody`]) so + /// the served backend can resolve its signer from the CURRENTLY-UNLOCKED custody session at + /// runtime (#368 runtime signer load) — not only from a signer injected once at construction. + /// `None` on the test/simulator path (which injects a fixed signer via [`Self::with_signer`]). + custody: Option, } /// The connected client's PUBLIC identity for a session (#407). Scoping data only — no key. @@ -111,6 +117,7 @@ impl WalletBackend { lineage: None, events: Arc::new(EventBus::default()), identity: Arc::new(RwLock::new(None)), + custody: None, } } @@ -120,6 +127,21 @@ impl WalletBackend { self } + /// Attach the node-custodied seed lifecycle (#370/#368). The served backend then resolves its + /// signer from the currently-unlocked custody session at runtime, so a paired caller's + /// `wallet.unlock` immediately enables signing/spend WITHOUT reconstructing the backend. A + /// bring-up-injected [`Self::with_signer`] still wins when present (the simulator path). + pub fn with_custody(mut self, custody: WalletCustody) -> Self { + self.custody = Some(custody); + self + } + + /// The node-custodied seed lifecycle, if attached (#368) — used by the transport layer to + /// dispatch the `wallet.*` custody methods to one shared custody state machine. + pub fn custody(&self) -> Option<&WalletCustody> { + self.custody.as_ref() + } + /// Attach the network broadcaster (enables `auto_submit` + `submit_transaction`). pub fn with_broadcaster(mut self, broadcaster: Arc) -> Self { self.broadcaster = Some(broadcaster); @@ -836,7 +858,7 @@ impl WalletBackend { /// The wallet's tracked p2 puzzle hashes (for summary "receiving" flags). fn wallet_puzzle_hashes(&self) -> HashSet { - if let Some(s) = &self.signer { + if let Some(s) = self.current_signer() { return s.puzzle_hashes(); } self.config @@ -846,16 +868,25 @@ impl WalletBackend { .collect() } - /// The signer, or a locked-wallet error (C.6: spends need node-custodied keys). - fn require_signer(&self) -> Result<&WalletSigner> { + /// The EFFECTIVE signing key (#368): the bring-up-injected signer if present, else the signer + /// loaded by the currently-unlocked node custody. `None` ⇒ the wallet is locked / no key is + /// available. Returns an owned `Arc` because the custody signer lives behind custody's own lock, + /// so it cannot be borrowed out of `&self`. + fn current_signer(&self) -> Option> { self.signer - .as_deref() + .clone() + .or_else(|| self.custody.as_ref().and_then(|c| c.signer())) + } + + /// The signer, or a locked-wallet error (C.6: spends need node-custodied keys). + fn require_signer(&self) -> Result> { + self.current_signer() .ok_or_else(|| Error::internal("wallet is locked: no signing key available")) } /// The change puzzle hash (the wallet's first receive address). fn change_ph(&self) -> Result { - if let Some(ph) = self.signer.as_ref().and_then(|s| s.change_puzzle_hash()) { + if let Some(ph) = self.current_signer().and_then(|s| s.change_puzzle_hash()) { return Ok(ph); } match self.config.puzzle_hashes.first() { @@ -902,7 +933,7 @@ impl WalletBackend { &self.wallet_puzzle_hashes(), )?; if auto_submit { - if let (Some(signer), Some(bc)) = (self.signer.as_ref(), self.broadcaster.as_ref()) { + if let (Some(signer), Some(bc)) = (self.current_signer(), self.broadcaster.as_ref()) { let sig = signer.sign(&coin_spends)?; bc.broadcast(&SpendBundle::new(coin_spends.clone(), sig)) .await?; @@ -920,6 +951,7 @@ impl WalletBackend { async fn send_xch(&self, req: &SendXch) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let amount = amount_u64(&req.amount)?; let fee = amount_u64(&req.fee)?; let dest = self.decode_ph(&req.address)?; @@ -934,6 +966,7 @@ impl WalletBackend { async fn bulk_send_xch(&self, req: &BulkSendXch) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let amount = amount_u64(&req.amount)?; let fee = amount_u64(&req.fee)?; let dests = req @@ -952,6 +985,7 @@ impl WalletBackend { async fn combine(&self, req: &Combine) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let fee = amount_u64(&req.fee)?; let inputs = self.coins_from_ids(&req.coin_ids).await?; let coin_spends = spend::build_combine(signer, &inputs, self.change_ph()?, fee)?; @@ -960,6 +994,7 @@ impl WalletBackend { async fn split(&self, req: &Split) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let fee = amount_u64(&req.fee)?; let inputs = self.coins_from_ids(&req.coin_ids).await?; let coin_spends = @@ -969,6 +1004,7 @@ impl WalletBackend { async fn multi_send(&self, req: &MultiSend) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let fee = amount_u64(&req.fee)?; let mut payments = Vec::with_capacity(req.payments.len()); for p in &req.payments { @@ -1031,6 +1067,7 @@ impl WalletBackend { async fn send_cat(&self, req: &SendCat) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let amount = amount_u64(&req.amount)?; let fee = amount_u64(&req.fee)?; let dest = self.decode_ph(&req.address)?; @@ -1050,6 +1087,7 @@ impl WalletBackend { async fn bulk_send_cat(&self, req: &BulkSendCat) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let amount = amount_u64(&req.amount)?; let fee = amount_u64(&req.fee)?; let outputs = req @@ -1073,6 +1111,7 @@ impl WalletBackend { async fn sign_coin_spends(&self, req: &SignCoinSpends) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let coin_spends = req .coin_spends .iter() @@ -1192,6 +1231,7 @@ impl WalletBackend { async fn make_offer(&self, req: &MakeOffer) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let fee = amount_u64(&req.fee)?; let receive_ph = match &req.receive_address { Some(a) => self.decode_ph(a)?, @@ -1259,6 +1299,7 @@ impl WalletBackend { async fn take_offer(&self, req: &TakeOffer) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let fee = amount_u64(&req.fee)?; let change = self.change_ph()?; @@ -1342,6 +1383,7 @@ impl WalletBackend { async fn cancel_offer(&self, req: &CancelOffer) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let fee = amount_u64(&req.fee)?; let change = self.change_ph()?; let row = self @@ -1357,6 +1399,7 @@ impl WalletBackend { async fn create_did(&self, req: &CreateDid) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let fee = amount_u64(&req.fee)?; let inputs = spend::select_coins(self.spendable_coins(None).await?, 1u64.saturating_add(fee))?; @@ -1367,6 +1410,7 @@ impl WalletBackend { async fn bulk_mint_nfts(&self, req: &BulkMintNfts) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let fee = amount_u64(&req.fee)?; let did = self.resolve_did(&req.did_id).await?; let default_owner = self.change_ph()?; @@ -1409,7 +1453,7 @@ impl WalletBackend { &self.wallet_puzzle_hashes(), )?; if req.auto_submit { - if let (Some(s), Some(bc)) = (self.signer.as_ref(), self.broadcaster.as_ref()) { + if let (Some(s), Some(bc)) = (self.current_signer(), self.broadcaster.as_ref()) { let sig = s.sign(&coin_spends)?; bc.broadcast(&SpendBundle::new(coin_spends.clone(), sig)) .await?; @@ -1428,6 +1472,7 @@ impl WalletBackend { async fn transfer_nfts(&self, req: &TransferNfts) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let fee = amount_u64(&req.fee)?; let dest = self.decode_ph(&req.address)?; let mut nfts = Vec::with_capacity(req.nft_ids.len()); @@ -1446,6 +1491,7 @@ impl WalletBackend { async fn transfer_dids(&self, req: &TransferDids) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let fee = amount_u64(&req.fee)?; let dest = self.decode_ph(&req.address)?; let mut dids = Vec::with_capacity(req.did_ids.len()); @@ -1520,6 +1566,7 @@ impl WalletBackend { async fn mint_option(&self, req: &MintOption) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let fee = amount_u64(&req.fee)?; if req.underlying.asset_id.is_some() { return Err(Error::api( @@ -1591,7 +1638,7 @@ impl WalletBackend { .await?; if req.auto_submit { - if let (Some(s), Some(bc)) = (self.signer.as_ref(), self.broadcaster.as_ref()) { + if let (Some(s), Some(bc)) = (self.current_signer(), self.broadcaster.as_ref()) { let sig = s.sign(&coin_spends)?; bc.broadcast(&SpendBundle::new(coin_spends.clone(), sig)) .await?; @@ -1610,6 +1657,7 @@ impl WalletBackend { async fn transfer_options(&self, req: &TransferOptions) -> Result { let signer = self.require_signer()?; + let signer = signer.as_ref(); let fee = amount_u64(&req.fee)?; let dest = self.decode_ph(&req.address)?; let mut opts = Vec::with_capacity(req.option_ids.len()); @@ -2100,6 +2148,10 @@ impl WalletBackend { let r = req!(SetChangeAddress); json(self.set_change_address(&r).await?)? } + // The node-custodied seed lifecycle (#370/#368): create/import/restore/unlock/lock/ + // status/delete. Reachable only when custody is attached ([`Self::with_custody`]); + // authorization is the transport's concern (the paired-token gate, SPEC §7.12). + m if m.starts_with("wallet.") => self.dispatch_custody(m, body)?, other => { return Err(Error::not_found(format!( "unknown or unsupported method: {other}" @@ -2108,6 +2160,80 @@ impl WalletBackend { }; serde_json::to_string(&value).map_err(|e| Error::internal(format!("serialize: {e}"))) } + + /// The first-class sync-status snapshot (#369): the tri-state ([`SyncLifecycle`]) derived from + /// the wallet DB's synced peak + initial-catch-up flag, plus the synced peak height. The WS + /// transport pushes this whenever it changes; the resolver/content transport is untouched. + pub async fn sync_status(&self) -> Result { + use super::events::{SyncLifecycle, SyncStatus}; + let st = self.db.sync_state().await?; + Ok(SyncStatus { + state: if st.initial_sync_complete { + SyncLifecycle::Synced + } else { + SyncLifecycle::Syncing + }, + peak_height: st.peak_height, + // Until a chain tip is separately tracked, the best-known target is the synced peak. + target_height: st.peak_height, + }) + } + + /// Dispatch a `wallet.*` custody-lifecycle method to the attached [`WalletCustody`] (#368). + /// Returns the Sage-shaped result value; a node without custody attached reports the method + /// as unavailable. Password/mnemonic params are read from the JSON body. + fn dispatch_custody(&self, method: &str, body: &str) -> Result { + #[derive(serde::Deserialize, Default)] + struct PwReq { + #[serde(default)] + password: String, + } + #[derive(serde::Deserialize, Default)] + struct MnemonicReq { + #[serde(default)] + mnemonic: String, + #[serde(default)] + password: String, + } + let custody = self + .custody + .as_ref() + .ok_or_else(|| Error::internal("wallet custody is not enabled on this node"))?; + let body_s = if body.trim().is_empty() { "{}" } else { body }; + let parse_pw = || -> Result { + serde_json::from_str(body_s) + .map_err(|e| Error::api(format!("invalid request for {method}: {e}"))) + }; + let parse_mn = || -> Result { + serde_json::from_str(body_s) + .map_err(|e| Error::api(format!("invalid request for {method}: {e}"))) + }; + let addr = |address: String| json(serde_json::json!({ "address": address })); + match method { + "wallet.status" => json(custody.status()), + "wallet.create" => addr(custody.create(&parse_pw()?.password)?), + "wallet.import" => { + let r = parse_mn()?; + addr(custody.import(&r.mnemonic, &r.password)?) + } + "wallet.restore" => { + let r = parse_mn()?; + addr(custody.restore(&r.mnemonic, &r.password)?) + } + "wallet.unlock" => addr(custody.unlock(&parse_pw()?.password)?), + "wallet.lock" => { + custody.lock(); + json(serde_json::json!({ "state": "locked" })) + } + "wallet.delete" => { + custody.delete(&parse_pw()?.password)?; + json(serde_json::json!({ "state": "none" })) + } + other => Err(Error::not_found(format!( + "unknown or unsupported method: {other}" + ))), + } + } } // ---- free helpers --------------------------------------------------------- @@ -3215,4 +3341,145 @@ mod tests { ); assert_eq!(resp.cats[0].balance.to_u64(), Some(1_000)); } + + // ---- #368: runtime signer load via node custody + wallet.* dispatch + sync-status -------- + + use super::super::custody::{Network, WalletCustody}; + use super::super::events::SyncLifecycle; + + /// A fresh custody manager over a unique temp seed path (no file yet), covering a small HD + /// range so the key-build stays fast. + fn fresh_custody() -> WalletCustody { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "dig-wallet-rpc-custody-{}-{}", + std::process::id(), + n + )); + let _ = std::fs::remove_dir_all(&dir); + WalletCustody::new(dir.join("seed.bin"), Network::Testnet11, 2) + } + + async fn custody_backend() -> WalletBackend { + let db = WalletDb::open_in_memory().await.unwrap(); + db.set_initial_sync_complete(true).await.unwrap(); + WalletBackend::new( + db, + Arc::new(MockFallback::default()), + WalletConfig::default(), + ) + .with_custody(fresh_custody()) + } + + /// **Proves (#368 runtime signer load):** a served backend resolves its signer from the + /// node-custodied session at RUNTIME. With no wallet, a spend method fails "locked"; after a + /// `wallet.create` over the same dispatch surface, the same method no longer reports locked + /// (it fails later, at coin selection) — i.e. `require_signer` succeeded without a bring-up + /// `with_signer`. + #[tokio::test] + async fn custody_unlock_enables_the_signer_at_runtime() { + let be = custody_backend().await; + let send = r#"{"address":"txch1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqp7z9wc","amount":1,"fee":0,"auto_submit":false}"#; + + // Locked: no signer available yet. + let (status, body) = be.dispatch("send_xch", send).await; + assert_ne!(status, 200); + assert!( + body.contains("locked") || body.contains("signing key"), + "expected a locked-wallet error, got: {body}" + ); + + // Create a wallet over the custody dispatch surface — leaves it unlocked with a signer. + let (status, body) = be + .dispatch("wallet.create", r#"{"password":"hunter2pw"}"#) + .await; + assert_eq!(status, 200, "wallet.create failed: {body}"); + assert!(body.contains("address"), "wallet.create returns an address"); + + // Now the signer resolves: send_xch no longer reports locked (fails at coin selection). + let (_status, body2) = be.dispatch("send_xch", send).await; + assert!( + !body2.contains("no signing key") && !body2.contains("wallet is locked"), + "signer must be resolved after unlock, got: {body2}" + ); + } + + /// **Proves:** the `wallet.*` custody lifecycle is dispatchable on the served surface — + /// status transitions none → unlocked → locked → unlocked → none across the lifecycle calls. + #[tokio::test] + async fn wallet_custody_lifecycle_over_dispatch() { + let be = custody_backend().await; + + let state = |body: &str| -> String { + serde_json::from_str::(body).unwrap()["state"] + .as_str() + .unwrap() + .to_string() + }; + + let (_s, body) = be.dispatch("wallet.status", "{}").await; + assert_eq!(state(&body), "none"); + + let (s, _b) = be + .dispatch("wallet.create", r#"{"password":"hunter2pw"}"#) + .await; + assert_eq!(s, 200); + let (_s, body) = be.dispatch("wallet.status", "{}").await; + assert_eq!(state(&body), "unlocked"); + + let (s, _b) = be.dispatch("wallet.lock", "{}").await; + assert_eq!(s, 200); + let (_s, body) = be.dispatch("wallet.status", "{}").await; + assert_eq!(state(&body), "locked"); + + let (s, _b) = be + .dispatch("wallet.unlock", r#"{"password":"hunter2pw"}"#) + .await; + assert_eq!(s, 200); + let (_s, body) = be.dispatch("wallet.status", "{}").await; + assert_eq!(state(&body), "unlocked"); + + let (s, _b) = be + .dispatch("wallet.delete", r#"{"password":"hunter2pw"}"#) + .await; + assert_eq!(s, 200); + let (_s, body) = be.dispatch("wallet.status", "{}").await; + assert_eq!(state(&body), "none"); + } + + /// **Proves:** a wrong password on `wallet.unlock` fails closed (`401`) — the paired caller + /// cannot brute the seed for free over the dispatch surface. + #[tokio::test] + async fn wallet_unlock_wrong_password_fails_closed() { + let be = custody_backend().await; + be.dispatch("wallet.create", r#"{"password":"correcthorse"}"#) + .await; + be.dispatch("wallet.lock", "{}").await; + let (status, _body) = be + .dispatch("wallet.unlock", r#"{"password":"wrong"}"#) + .await; + assert_eq!(status, 401, "wrong password must be 401"); + } + + /// **Proves (#369 sync-status):** the sync-status snapshot derives the tri-state from the DB — + /// `syncing` before the initial catch-up completes, `synced` (with the peak height) after. + #[tokio::test] + async fn sync_status_reports_tristate_from_db() { + let db = WalletDb::open_in_memory().await.unwrap(); + db.set_initial_sync_complete(false).await.unwrap(); + let be = WalletBackend::new( + db.clone(), + Arc::new(MockFallback::default()), + WalletConfig::default(), + ); + let s = be.sync_status().await.unwrap(); + assert_eq!(s.state, SyncLifecycle::Syncing); + + db.set_peak(123, "aa").await.unwrap(); + db.set_initial_sync_complete(true).await.unwrap(); + let s = be.sync_status().await.unwrap(); + assert_eq!(s.state, SyncLifecycle::Synced); + assert_eq!(s.peak_height, Some(123)); + } } diff --git a/crates/dig-wallet/src/sage/service.rs b/crates/dig-wallet/src/sage/service.rs new file mode 100644 index 0000000..3deecbe --- /dev/null +++ b/crates/dig-wallet/src/sage/service.rs @@ -0,0 +1,167 @@ +//! Production assembly of the SERVED Sage-parity wallet backend (#368). +//! +//! [`serve_dual`](super::transport::serve_dual) / [`WalletBackend`] existed but had **no +//! production call site** — the shipped `dig-node` never built or served the wallet surface, so +//! the extension's node-first reads ran against a mock, not the installed binary. This module is +//! that missing bring-up: it assembles one live [`WalletBackend`] (the local wallet DB + a +//! graceful fallback tier + a shared [`EventBus`] + the node-custodied seed lifecycle) plus the +//! shared mTLS cert, ready for the dig-node service shell to serve over its loopback transports. +//! +//! The assembly is deliberately **offline-safe and non-blocking**: it opens (or creates) the +//! SQLite wallet DB under the node config dir, and defaults the fallback tier to the graceful +//! [`EmptyFallback`] so bring-up never waits on network/TLS peer discovery. The live direct-peer +//! sync loop (which would swap in the [`CoinsetFallback`](super::fallback::CoinsetFallback) and +//! feed the DB) remains the documented remaining integration (SPEC §18.12); the [`EventBus`] is +//! wired here so that loop — and the WS sync-status push (#369) — publish to one shared bus. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use super::custody::WalletCustody; +use super::db::WalletDb; +use super::events::EventBus; +use super::fallback::EmptyFallback; +use super::rpc::{WalletBackend, WalletConfig}; +use super::transport::SharedCert; + +/// A fully-assembled, ready-to-serve wallet: the dispatch backend, the shared event bus the WS +/// transport (#369) subscribes to, and the shared self-signed cert the mTLS listener presents. +/// The node-custodied seed lifecycle is reachable via [`WalletBackend::custody`] — the backend +/// resolves its signer from it at runtime (#368), so a paired `wallet.unlock` immediately enables +/// signing without reconstructing the backend. +#[derive(Clone)] +pub struct WalletService { + /// The one dispatch handler set both loopback transports (HTTP mirror + mTLS) call. + pub backend: Arc, + /// The sync-event bus the (future) live sync loop publishes to and the WS transport reads. + pub events: Arc, + /// The shared self-signed cert the mTLS `9257` listener presents (Sage byte-parity). + pub cert: SharedCert, +} + +impl WalletService { + /// Assemble the served wallet under `config_dir` (the node's config directory). The wallet DB + /// is `/wallet.sqlite`; the encrypted seed is `/wallet-seed.bin` + /// (mainnet custody). Never blocks on network: the fallback tier defaults to + /// [`EmptyFallback`]. A DB-open failure falls back to an in-memory DB so the node still serves + /// the version/custody/sync-status surface (reported, not fatal). + pub async fn build(config_dir: &Path) -> WalletService { + let events = Arc::new(EventBus::default()); + let db = open_db(config_dir).await; + let custody = WalletCustody::mainnet(seed_path(config_dir)); + let backend = Arc::new( + WalletBackend::new(db, Arc::new(EmptyFallback), WalletConfig::default()) + .with_events(events.clone()) + .with_custody(custody), + ); + // A generated shared cert is fine for a loopback listener: whoever can reach the loopback + // mTLS port and present the matching cert is a local node-class client. A persisted cert + // (so a separate node-class process can read it) is the follow-up when that client lands. + let cert = SharedCert::generate().expect("dig-wallet: generate mTLS cert"); + WalletService { + backend, + events, + cert, + } + } +} + +/// The wallet DB path under the node config dir. +fn db_path(config_dir: &Path) -> PathBuf { + config_dir.join("wallet.sqlite") +} + +/// The encrypted-seed path under the node config dir. +fn seed_path(config_dir: &Path) -> PathBuf { + config_dir.join("wallet-seed.bin") +} + +/// Open the on-disk wallet DB, falling back to an in-memory DB (reported) if the on-disk open +/// fails — so a broken/unwritable data dir degrades the wallet to non-persistent rather than +/// aborting the whole node. +async fn open_db(config_dir: &Path) -> WalletDb { + let _ = std::fs::create_dir_all(config_dir); + let path = db_path(config_dir); + match path.to_str() { + Some(p) => match WalletDb::open(p).await { + Ok(db) => db, + Err(e) => { + eprintln!( + "dig-node: WARN could not open the wallet DB at {} ({e}); using an \ + in-memory wallet DB (wallet state will not persist across restarts)", + path.display() + ); + in_memory_db().await + } + }, + None => in_memory_db().await, + } +} + +/// A last-resort in-memory wallet DB (used only when the on-disk open failed). +async fn in_memory_db() -> WalletDb { + WalletDb::open_in_memory() + .await + .expect("dig-wallet: open in-memory wallet DB") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A unique temp config dir per test. + fn scratch() -> PathBuf { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("dig-wallet-svc-{}-{}", std::process::id(), n)); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + /// **Proves (#368):** the production assembler builds a served backend that answers + /// `get_version` over the transport-independent dispatch, carries the node custody lifecycle + /// (`wallet.status` = `none` on a fresh dir), and shares one event bus. + #[tokio::test] + async fn build_assembles_a_served_backend() { + let dir = scratch(); + let svc = WalletService::build(&dir).await; + + let (status, body) = svc.backend.dispatch("get_version", "{}").await; + assert_eq!(status, 200, "{body}"); + assert!(body.contains(env!("CARGO_PKG_VERSION"))); + + // Custody is attached and reports a fresh (no-seed) wallet. + let (status, body) = svc.backend.dispatch("wallet.status", "{}").await; + assert_eq!(status, 200); + assert!(body.contains("none"), "fresh dir has no wallet: {body}"); + + // The backend shares the service's event bus (a publish is visible to a subscriber). + assert_eq!(svc.backend.events().subscriber_count(), 0); + assert!(std::ptr::eq( + Arc::as_ptr(svc.backend.events()), + Arc::as_ptr(&svc.events) + )); + } + + /// **Proves:** the DB persists across two builds over the same dir (a created wallet is still + /// present) — the served backend is durable, not in-memory, in the normal case. + #[tokio::test] + async fn on_disk_db_and_seed_persist_across_builds() { + let dir = scratch(); + { + let svc = WalletService::build(&dir).await; + let (s, _b) = svc + .backend + .dispatch("wallet.create", r#"{"password":"hunter2pw"}"#) + .await; + assert_eq!(s, 200); + } + // A second build over the same dir sees the persisted (locked) wallet. + let svc2 = WalletService::build(&dir).await; + let (_s, body) = svc2.backend.dispatch("wallet.status", "{}").await; + assert!( + body.contains("locked"), + "the persisted seed must reopen as locked: {body}" + ); + } +}