diff --git a/Cargo.lock b/Cargo.lock index 5c48864..210708f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2281,7 +2281,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.74.4" +version = "0.75.0" dependencies = [ "async-trait", "axum", @@ -2455,7 +2455,7 @@ dependencies = [ [[package]] name = "dig-wallet" -version = "0.11.0" +version = "0.12.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 5798e86..d1e9652 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[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.74.4" +version = "0.75.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 f917c02..be79bfc 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1022,7 +1022,10 @@ Two layers, both REQUIRED: Exactly the `control.` method prefix is gated (`is_control_method`); unknown `control.*` methods still pass the auth gate first, then yield `METHOD_NOT_FOUND`. The pairing-administration methods (`control.pairing.list`/`approve`/`revoke`, §7.11) require the MASTER token specifically — a paired -token is NOT accepted for them. +token is NOT accepted for them. The ONE exception is `control.wallet.balance` (`is_open_control_read`): +a READ-ONLY chain read of a PUBLIC address with no custody, so it is served WITHOUT a token like the +other reads — it still routes through `dispatch_control` (so it stays in the control catalog and gets +its CLI verb) but the server skips the token requirement. No mutation or custody method is ever open. ### 7.3. The control token @@ -1200,6 +1203,7 @@ lowercase 64-hex; a capsule reference is `storeId:rootHash`. Malformed refs yiel | `control.hostedStores.status` | `store` = `storeId[:rootHash]` | `store_id`, `pinned`, `capsule_count`, `total_bytes`, `capsules[]` | | `control.sync.status` | — | `available` (always `true` — the chunked capsule download needs no identity), `method: "chunked-capsule-download-with-section-21-clone-fallback"`, `identity_loaded`, `pinned_total`, `pinned_synced`, `whole_store_trigger_supported` (`true` — a store id alone is enough) | | `control.sync.trigger` | `store` = `storeId[:rootHash]`, or `store_id` [+ `root`] — the root is OPTIONAL; without one the node resolves the store's CHAIN-ANCHORED tip and syncs that generation | `status: "synced"`, `root`, `size_bytes`, `served_root` | +| `control.wallet.balance` | `address` (bech32m string), `asset` (`"xch"` \| `"dig"`, default `"xch"`) | `balance` (confirmed, spendable — JSON NUMBER, u64 base units), `pending` (unspent + unconfirmed — JSON NUMBER, u64 base units), `synced` (bool — whether a fully-synced view answered), `peak_height` (the node's chain-view peak, or `null`). Matches `dig-node-control-interface` 0.3.0's `WalletBalanceResult { balance: u64, pending: u64, .. }` and dig-app's `BalanceResponse { balance: u64 }` — a Rust-to-Rust numeric contract, never a decimal string. The wallet backend tracks the base-unit total as `u128` (headroom for summed intermediate math); the wire boundary saturating-casts to `u64` (a single address's balance can never exceed `u64::MAX` mojos, ~18.4M XCH). READ-ONLY chain read of a PUBLIC address (no seed/signing key). Reuses the B.6 sync-state routing: the local DB when the address is the wallet's own and the DB is synced, else the coinset fallback. `$DIG` scopes by the canonical CAT asset id `digstore_chain::dig::DIG_ASSET_ID`. A synced empty address is a SUCCESS `{balance:0, synced:true}`, never an error; the three read-failure shapes are DISTINCT errors `WALLET_NO_CHAIN_SOURCE`/`WALLET_NOT_SYNCED`/`WALLET_READ_FAILED` (§10), never a fabricated `0`. `INVALID_PARAMS` on a missing/malformed `address` or a bad `asset`. | ### 7.5. Ownership boundary @@ -2046,7 +2050,8 @@ method-not-found cue). `privacy_requires_local_node` / `onion_hops_out_of_range`) — the published normative contract on docs.dig.net — and MUST NOT be used for control. (`dig-rpc-protocol` is the source of this resolution; any client that branched on the old control numbers keys on the symbolic `data.code`, not the -number.) +number.) The wallet-read errors occupy `-3204x` (`WALLET_NO_CHAIN_SOURCE` / `WALLET_NOT_SYNCED` / +`WALLET_READ_FAILED`); `-3205x` is owned by the chat plane (§ chat) and MUST NOT collide. | Code | Name | Origin | Meaning | |---|---|---|---| @@ -2065,6 +2070,9 @@ number.) | -32030 | `UNAUTHORIZED` | shell | `control.*` called without a valid local control token. | | -32031 | `NOT_SUPPORTED` | shell | A control operation this build/pin cannot perform (e.g. §21 sync without an identity). | | -32032 | `CONTROL_ERROR` | shell | A control operation failed at runtime (distinct from bad input / absent capability). | +| -32040 | `WALLET_NO_CHAIN_SOURCE` | node | `control.wallet.balance` (§ control catalog) had NO live chain source able to answer an arbitrary (non-wallet) address. Distinct from a truthful `0`. | +| -32041 | `WALLET_NOT_SYNCED` | node | `control.wallet.balance` of the wallet's OWN address while the local DB is still syncing and no live fallback is attached (nothing can answer yet). | +| -32042 | `WALLET_READ_FAILED` | node | `control.wallet.balance` failed at the underlying DB / chain-source layer. Distinct from `WALLET_NO_CHAIN_SOURCE` and `WALLET_NOT_SYNCED`. | Read-path and upstream errors outside this table are relayed verbatim; this catalogue governs what the **shell** mints plus the cross-boundary codes a client must be able to branch on. diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 2428cba..822a7e0 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -78,6 +78,18 @@ pub fn is_control_method(method: &str) -> bool { method.starts_with("control.") } +/// Is this a control method that is READ-ONLY and safe to answer WITHOUT the control token — +/// an OPEN read on the loopback read plane? PURE. +/// +/// `control.wallet.balance` (#1851) is a chain-read of a PUBLIC address (no seed, no signing +/// key, no custody), so it is exposed like the other reads rather than behind the control-token +/// gate: a local UI can poll a balance without pairing. It is still routed through the control +/// dispatcher (so it stays discoverable in [`CONTROL_METHODS`] and gets its CLI verb), but the +/// server skips the token requirement for it. NO mutation or custody method is ever open here. +pub fn is_open_control_read(method: &str) -> bool { + method == "control.wallet.balance" +} + /// The canonical set of `control.*` methods the node's control plane RESOLVES — the /// union of the methods this shell owns ([`dispatch_control`]) and the ones it delegates /// to the embedded node's own control surface (`control.peerStatus` + @@ -106,6 +118,7 @@ pub const CONTROL_METHODS: &[&str] = &[ "control.hostedStores.status", "control.sync.status", "control.sync.trigger", + "control.wallet.balance", "control.updater.status", "control.updater.setChannel", "control.updater.pause", @@ -142,6 +155,7 @@ pub const OWNED_CONTROL_METHODS: &[&str] = &[ "control.hostedStores.status", "control.sync.status", "control.sync.trigger", + "control.wallet.balance", "control.updater.status", "control.updater.setChannel", "control.updater.pause", @@ -661,6 +675,9 @@ pub struct ControlCtx { /// `pairing.request`/`pairing.poll` handlers so an operator-approved pairing /// becomes pollable by the requesting extension. pub pairings: Arc>, + /// The node-custodied wallet backend (#368), for the READ-ONLY `control.wallet.balance` + /// chain read (#1851). A public-address balance read only — never a spend/custody path. + pub wallet: Arc, } /// Dispatch a single authorized CONTROL method. The caller has ALREADY enforced the @@ -710,6 +727,7 @@ async fn dispatch_owned(ctx: &ControlCtx, id: Value, method: &str, params: &Valu "control.hostedStores.status" => hosted_status(ctx, id, params).await, "control.sync.status" => control_ok(id, sync_status(ctx).await), "control.sync.trigger" => sync_trigger(ctx, id, params).await, + "control.wallet.balance" => wallet_balance(ctx, id, params).await, // The DIG auto-update beacon proxy (#515) — a THIN passthrough to `dig-updater`'s // own status file + CLI (see `crate::updater`'s module doc for why nothing here // re-implements the beacon's trust/install logic). @@ -1165,6 +1183,77 @@ async fn sync_trigger(ctx: &ControlCtx, id: Value, params: &Value) -> Value { } } +/// Maps the wallet backend's [`dig_wallet::sage::rpc::WalletBalanceResult`] (internally `u128`, +/// to leave headroom for summed intermediate math) onto the wire contract published by +/// `dig-node-control-interface` 0.3.0 and consumed by dig-app's `BalanceResponse`: `balance`/ +/// `pending` as JSON **numbers** fitting `u64` (a single address's balance can never exceed +/// `u64::MAX` mojos, ~18.4M XCH), never JSON strings. Saturates rather than panicking on an +/// implausible overflow, since a clamped-but-alive response beats a crashed RPC call. +fn balance_wire(r: &dig_wallet::sage::rpc::WalletBalanceResult) -> Value { + json!({ + "balance": u64::try_from(r.balance).unwrap_or(u64::MAX), + "pending": u64::try_from(r.pending).unwrap_or(u64::MAX), + "synced": r.synced, + "peak_height": r.peak_height, + }) +} + +/// `control.wallet.balance` (#1851) — the READ-ONLY balance of a PUBLIC address, for XCH or +/// $DIG. An OPEN read (no token gate, [`is_open_control_read`]): it needs only an address, never +/// a seed or signing key, so it carries zero custody risk. It reuses the wallet backend's B.6 +/// sync-state routing ([`dig_wallet::sage::rpc::WalletBackend::balance_for_address`]). +/// +/// Params: `{ address (bech32m string), asset ("xch" | "dig") }`. Result: +/// `{ balance, pending, synced, peak_height }`. A synced empty address is a SUCCESS with a zero +/// figure; the three read-failure shapes map to DISTINCT catalogued errors (never a fabricated +/// `0`): `WALLET_NO_CHAIN_SOURCE`, `WALLET_NOT_SYNCED`, `WALLET_READ_FAILED`. +async fn wallet_balance(ctx: &ControlCtx, id: Value, params: &Value) -> Value { + use dig_wallet::sage::rpc::{BalanceAsset, BalanceError}; + + let Some(address) = params.get("address").and_then(|v| v.as_str()) else { + return control_error( + id, + ErrorCode::InvalidParams, + "control.wallet.balance requires params.address (a bech32m address string)", + ); + }; + let asset_str = params + .get("asset") + .and_then(|v| v.as_str()) + .unwrap_or("xch"); + let Some(asset) = BalanceAsset::from_wire(asset_str) else { + return control_error( + id, + ErrorCode::InvalidParams, + format!("control.wallet.balance asset must be \"xch\" or \"dig\", got {asset_str:?}"), + ); + }; + + match ctx.wallet.balance_for_address(address, asset).await { + Ok(r) => control_ok(id, balance_wire(&r)), + Err(BalanceError::InvalidAddress) => control_error( + id, + ErrorCode::InvalidParams, + format!("control.wallet.balance: {address:?} is not a valid bech32m address"), + ), + Err(BalanceError::NoChainSource) => control_error( + id, + ErrorCode::WalletNoChainSource, + "no live chain source could answer this balance read", + ), + Err(BalanceError::NotSynced) => control_error( + id, + ErrorCode::WalletNotSynced, + "the wallet is still syncing and no fallback is available to answer", + ), + Err(BalanceError::ReadFailed(e)) => control_error( + id, + ErrorCode::WalletReadFailed, + format!("balance read failed: {e}"), + ), + } +} + /// Count distinct store ids among the cached capsules. PURE-ish (reads the slice). fn distinct_store_count(cached: &[dig_node_core::CachedCapsule]) -> usize { cached @@ -1644,4 +1733,67 @@ mod tests { assert_eq!(read_upstream_override_from(&config_path), None); let _ = std::fs::remove_dir_all(&dir); } + + /// (#1851 leg-2) `control.wallet.balance` MUST emit `balance`/`pending` as JSON **numbers**, + /// matching `dig-node-control-interface` 0.3.0's `WalletBalanceResult { balance: u64, .. }` + /// and dig-app-core's `BalanceResponse { balance: u64 }`. This is the property under test — + /// distinguished from the nearest wrong implementation (`r.balance.to_string()`, which + /// produces a `Value::String` that LOOKS identical when printed but fails `u64` deserialize) + /// by asserting deserialization into a `u64`-typed mirror struct, not just string-equality + /// against the printed JSON. + #[test] + fn balance_wire_emits_numeric_amounts_matching_app_contract() { + use dig_wallet::sage::rpc::WalletBalanceResult; + + #[derive(serde::Deserialize)] + struct AppBalance { + balance: u64, + } + + let r = WalletBalanceResult { + balance: 12_345, + pending: 6, + synced: true, + peak_height: Some(42), + }; + let emitted = balance_wire(&r); + + // Golden shape: numeric, not string. + assert_eq!( + emitted, + json!({"balance": 12345u64, "pending": 6u64, "synced": true, "peak_height": 42}), + ); + assert!( + emitted["balance"].is_number(), + "balance must be a JSON number, not a string" + ); + assert!( + emitted["pending"].is_number(), + "pending must be a JSON number, not a string" + ); + + // Load-bearing: dig-app's numeric-typed struct deserializes cleanly from the emitted + // value. A `.to_string()`-based emission (`Value::String("12345")`) fails THIS + // assertion with a "invalid type: string, expected u64" error. + let app: AppBalance = + serde_json::from_value(emitted).expect("numeric balance must deserialize into u64"); + assert_eq!(app.balance, 12_345); + } + + /// Saturating-cast guard: a `u128` balance beyond `u64::MAX` clamps rather than panicking, + /// so the RPC call stays alive (clamped-but-answered) instead of crashing on an implausible + /// overflow. + #[test] + fn balance_wire_saturates_u128_overflow_to_u64_max() { + use dig_wallet::sage::rpc::WalletBalanceResult; + + let r = WalletBalanceResult { + balance: u128::from(u64::MAX) + 1, + pending: 0, + synced: false, + peak_height: None, + }; + let emitted = balance_wire(&r); + assert_eq!(emitted["balance"], json!(u64::MAX)); + } } diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index 7f2d8f7..f0d0436 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -56,6 +56,8 @@ pub enum ControlAction { SyncStatus, /// `control.sync.trigger` — trigger a §21 sync for one capsule (`storeId:rootHash`). SyncTrigger { store: String }, + /// `control.wallet.balance` — the READ-ONLY balance of a public address (XCH or $DIG). + WalletBalance { address: String, asset: String }, /// `control.updater.status` — the DIG auto-update beacon's status. UpdaterStatus, /// `control.updater.setChannel` — set the beacon channel (`nightly` | `stable`). @@ -91,6 +93,7 @@ impl ControlAction { ControlAction::StoresStatus { .. } => "control.hostedStores.status", ControlAction::SyncStatus => "control.sync.status", ControlAction::SyncTrigger { .. } => "control.sync.trigger", + ControlAction::WalletBalance { .. } => "control.wallet.balance", ControlAction::UpdaterStatus => "control.updater.status", ControlAction::UpdaterSetChannel { .. } => "control.updater.setChannel", ControlAction::UpdaterPause { .. } => "control.updater.pause", @@ -111,6 +114,9 @@ impl ControlAction { | ControlAction::StoresUnpin { store } | ControlAction::StoresStatus { store } | ControlAction::SyncTrigger { store } => json!({ "store": store }), + ControlAction::WalletBalance { address, asset } => { + json!({ "address": address, "asset": asset }) + } ControlAction::UpdaterSetChannel { channel } => json!({ "channel": channel }), ControlAction::UpdaterPause { until: Some(u) } => json!({ "until": u }), ControlAction::SubsAdd { store_id } | ControlAction::SubsRemove { store_id } => { @@ -161,6 +167,11 @@ pub fn cli_covered_control_methods() -> Vec<&'static str> { store: String::new(), } .method(), + ControlAction::WalletBalance { + address: String::new(), + asset: String::new(), + } + .method(), ControlAction::UpdaterStatus.method(), ControlAction::UpdaterSetChannel { channel: String::new(), @@ -267,6 +278,16 @@ fn summarize(method: &str, result: &Value) -> String { "unsubscribed from {}", result["store_id"].as_str().unwrap_or("?"), ), + "control.wallet.balance" => format!( + "balance {} · pending {} · {}", + result["balance"].as_u64().unwrap_or(0), + result["pending"].as_u64().unwrap_or(0), + if result["synced"].as_bool().unwrap_or(false) { + "synced" + } else { + "syncing" + }, + ), "control.updater.status" => summarize_updater_status(result), _ => compact(result), } @@ -401,6 +422,22 @@ mod tests { assert!(s.contains("sync available")); } + /// REGRESSION (#1851): `control.wallet.balance` emits `balance`/`pending` as JSON NUMBERS + /// (not strings). The summary line MUST render the actual numeric values — a prior version + /// read them with `.as_str()`, which always misses on a JSON number and silently prints `?` + /// for both fields regardless of the real balance. + #[test] + fn wallet_balance_summary_renders_numeric_fields() { + let s = summarize( + "control.wallet.balance", + &json!({ "balance": 12345, "pending": 6, "synced": true, "peak_height": 42 }), + ); + assert!(s.contains("12345"), "got: {s}"); + assert!(s.contains('6'), "got: {s}"); + assert!(!s.contains('?'), "must not fall back to `?`: {s}"); + assert!(s.contains("synced"), "got: {s}"); + } + #[test] fn unknown_method_summary_falls_back_to_compact_json() { // A method with no bespoke line still prints SOMETHING readable (compact JSON). diff --git a/crates/dig-node-service/src/entrypoint.rs b/crates/dig-node-service/src/entrypoint.rs index 6bb12a6..e66ff26 100644 --- a/crates/dig-node-service/src/entrypoint.rs +++ b/crates/dig-node-service/src/entrypoint.rs @@ -142,6 +142,11 @@ enum Command { #[command(subcommand)] action: Option, }, + /// Read a public address's balance (the OPEN `control.wallet.balance` read, #1851). + Wallet { + #[command(subcommand)] + action: WalletCommand, + }, /// Drive the DIG auto-update beacon (the `control.updater.*` surface). Updater { #[command(subcommand)] @@ -227,6 +232,19 @@ enum SyncCommand { }, } +/// `dig-node wallet` sub-actions (#1851). +#[derive(Subcommand)] +enum WalletCommand { + /// Print the balance of a public address (READ-ONLY; needs no seed or pairing). + Balance { + /// The bech32m address to read (`xch1…`). + address: String, + /// The asset to total: `xch` (default) or `dig`. + #[arg(long, default_value = "xch")] + asset: String, + }, +} + /// `dig-node updater` sub-actions. With none, prints the beacon status. #[derive(Subcommand)] enum UpdaterCommand { @@ -331,6 +349,7 @@ impl Command { Command::Cache { .. } => "cache", Command::Stores { .. } => "stores", Command::Sync { .. } => "sync", + Command::Wallet { .. } => "wallet", Command::Updater { .. } => "updater", Command::Subscriptions { .. } => "subscriptions", Command::Peers { .. } => "peers", @@ -438,6 +457,9 @@ pub fn run() -> std::process::ExitCode { Command::Sync { action: cmd } => { render(control_cli::run(&config, sync_action(cmd)), action, json) } + Command::Wallet { action: cmd } => { + render(control_cli::run(&config, wallet_action(cmd)), action, json) + } Command::Updater { action: cmd } => { render(control_cli::run(&config, updater_action(cmd)), action, json) } @@ -490,6 +512,15 @@ fn sync_action(cmd: Option) -> ControlAction { } } +/// Map the `wallet` subcommand to its [`ControlAction`] (#1851). +fn wallet_action(cmd: WalletCommand) -> ControlAction { + match cmd { + WalletCommand::Balance { address, asset } => { + ControlAction::WalletBalance { address, asset } + } + } +} + /// Map the `updater` subcommand to its [`ControlAction`] (no sub-action → print beacon status). fn updater_action(cmd: Option) -> ControlAction { match cmd { diff --git a/crates/dig-node-service/src/meta.rs b/crates/dig-node-service/src/meta.rs index 17f7262..7922490 100644 --- a/crates/dig-node-service/src/meta.rs +++ b/crates/dig-node-service/src/meta.rs @@ -555,6 +555,18 @@ pub enum ErrorCode { /// `INVALID_PARAMS` (bad input) and `NOT_SUPPORTED` (capability absent). Shell. /// (Canonical dig-rpc-types §10 code; `-32022` is RESERVED for onion routing.) ControlError, + /// `-32040` — a wallet balance read (`control.wallet.balance`, #1851) had NO live chain + /// source able to answer an arbitrary (non-wallet) address. Distinct from a truthful `0`: + /// the node could not consult the chain, so it reports the read as unanswerable rather than + /// fabricating an empty balance. Node error. (Wallet range `-3204x`; chat owns `-3205x`.) + WalletNoChainSource, + /// `-32041` — a wallet balance read of the wallet's OWN address while the local DB is still + /// syncing and no live fallback is attached: nothing can answer yet. Node error. + WalletNotSynced, + /// `-32042` — a wallet balance read failed at the underlying DB / chain-source layer. + /// Distinct from `WALLET_NO_CHAIN_SOURCE` (nothing to ask) and `WALLET_NOT_SYNCED` + /// (not ready). Node error. + WalletReadFailed, } impl ErrorCode { @@ -571,6 +583,9 @@ impl ErrorCode { ErrorCode::Unauthorized => -32030, ErrorCode::NotSupported => -32031, ErrorCode::ControlError => -32032, + ErrorCode::WalletNoChainSource => -32040, + ErrorCode::WalletNotSynced => -32041, + ErrorCode::WalletReadFailed => -32042, } } @@ -588,6 +603,9 @@ impl ErrorCode { ErrorCode::Unauthorized => "UNAUTHORIZED", ErrorCode::NotSupported => "NOT_SUPPORTED", ErrorCode::ControlError => "CONTROL_ERROR", + ErrorCode::WalletNoChainSource => "WALLET_NO_CHAIN_SOURCE", + ErrorCode::WalletNotSynced => "WALLET_NOT_SYNCED", + ErrorCode::WalletReadFailed => "WALLET_READ_FAILED", } } @@ -605,6 +623,10 @@ impl ErrorCode { | ErrorCode::ControlError | ErrorCode::ParseError => "shell", ErrorCode::MethodNotFound => "boundary", + // The wallet balance read (#1851) is served by the node-custodied wallet backend. + ErrorCode::WalletNoChainSource + | ErrorCode::WalletNotSynced + | ErrorCode::WalletReadFailed => "node", // INVALID_PARAMS is returned by the embedded read path's locally-served // read methods (bad store_id / retrieval_key) before any I/O. ErrorCode::InvalidParams => "node", @@ -637,6 +659,13 @@ impl ErrorCode { "The requested control operation is not supported on this node build." } ErrorCode::ControlError => "A control operation failed at runtime.", + ErrorCode::WalletNoChainSource => { + "A wallet balance read had no live chain source to answer an arbitrary address." + } + ErrorCode::WalletNotSynced => { + "A wallet balance read of the wallet's own address is still syncing with no fallback." + } + ErrorCode::WalletReadFailed => "A wallet balance read failed at the DB / chain layer.", } } @@ -653,6 +682,9 @@ impl ErrorCode { ErrorCode::Unauthorized, ErrorCode::NotSupported, ErrorCode::ControlError, + ErrorCode::WalletNoChainSource, + ErrorCode::WalletNotSynced, + ErrorCode::WalletReadFailed, ] } } diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 3c7ccc9..3f69d9f 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -460,6 +460,7 @@ fn control_ctx(state: &AppState) -> ControlCtx { started: state.started, sync_available: state.sync_available, pairings: state.pairings.clone(), + wallet: state.wallet.clone(), } } @@ -781,34 +782,40 @@ async fn rpc( // DIG_NODE_ALLOW_REMOTE=1, #1662) is defense-in-depth beneath this gate, not the gate itself. // The READ methods below are NOT token-gated. if control::is_control_method(&method) { - let header_tok = headers - .get(control::CONTROL_TOKEN_HEADER) - .and_then(|v| v.to_str().ok()); - let presented = control::presented_token(header_tok, &req); - // Authorization is granted by EITHER the master control token OR — for a - // NON-administrative control method — a valid PAIRED token (#280). Pairing - // administration (list/approve/revoke) requires the MASTER token only, so a - // paired controller can neither mint more tokens nor revoke itself. - let master_ok = control::is_authorized(&method, presented.as_deref(), &state.control_token); - let paired_ok = !control::is_pairing_admin_method(&method) - && presented.as_deref().is_some_and(|tok| { - pairing::is_paired_token(&pairing::paired_tokens_path(&state.state_dir), tok) - }); - if !(master_ok || paired_ok) { - return ( - StatusCode::OK, - Json(control::control_error( - id, - ErrorCode::Unauthorized, - format!( - "control.* requires the local control token (X-Dig-Control-Token \ - header or params._control_token, from {}), or a paired controller \ - token (see `dig-node pair`). {}", - control::control_token_path().display(), - control::control_token_remedy() - ), - )), - ); + // An OPEN control READ (`control.wallet.balance`, #1851) is a public-address chain read + // with no custody — served WITHOUT the control token, like the other reads, while still + // routing through the control dispatcher below. Every other control method is token-gated. + if !control::is_open_control_read(&method) { + let header_tok = headers + .get(control::CONTROL_TOKEN_HEADER) + .and_then(|v| v.to_str().ok()); + let presented = control::presented_token(header_tok, &req); + // Authorization is granted by EITHER the master control token OR — for a + // NON-administrative control method — a valid PAIRED token (#280). Pairing + // administration (list/approve/revoke) requires the MASTER token only, so a + // paired controller can neither mint more tokens nor revoke itself. + let master_ok = + control::is_authorized(&method, presented.as_deref(), &state.control_token); + let paired_ok = !control::is_pairing_admin_method(&method) + && presented.as_deref().is_some_and(|tok| { + pairing::is_paired_token(&pairing::paired_tokens_path(&state.state_dir), tok) + }); + if !(master_ok || paired_ok) { + return ( + StatusCode::OK, + Json(control::control_error( + id, + ErrorCode::Unauthorized, + format!( + "control.* requires the local control token (X-Dig-Control-Token \ + header or params._control_token, from {}), or a paired controller \ + token (see `dig-node pair`). {}", + control::control_token_path().display(), + control::control_token_remedy() + ), + )), + ); + } } let params = req.get("params").cloned().unwrap_or(json!({})); let ctx = control_ctx(&state); diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index 018ffef..f619664 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-wallet" -version = "0.11.0" +version = "0.12.0" edition = "2021" license = "GPL-2.0-only" description = "DIG Browser built-in Chia wallet sidecar: a local axum server (using digstore-chain + chia-wallet-sdk over coinset.org) that serves a Sage-mirroring wallet UI. Native Rust so BLS signing works; the browser opens it at 127.0.0.1." diff --git a/crates/dig-wallet/src/sage/db.rs b/crates/dig-wallet/src/sage/db.rs index 216d465..c712605 100644 --- a/crates/dig-wallet/src/sage/db.rs +++ b/crates/dig-wallet/src/sage/db.rs @@ -792,6 +792,59 @@ impl WalletDb { .sum()) } + /// The PENDING balance for `asset_id` scoped to `puzzle_hashes`: the sum of coins that + /// are unspent AND not yet confirmed on-chain (`spent_height IS NULL AND created_height + /// IS NULL` — a coin the wallet has created/received but that has not landed in a block). + /// Distinct from [`Self::balance_scoped`], which counts ONLY confirmed unspent coins + /// (`created_height IS NOT NULL`). Used by the `control.wallet.balance` read (#1851) to + /// report `{ balance, pending }` separately so a caller never conflates in-flight value + /// with spendable value. + pub async fn pending_scoped( + &self, + asset_id: Option<&str>, + puzzle_hashes: &[String], + ) -> sqlx::Result { + if puzzle_hashes.is_empty() { + return Ok(0); + } + let ph = Self::placeholders(puzzle_hashes.len()); + let (scope_col, asset_clause) = match asset_id { + Some(_) => ("hint", "AND asset_id = ?"), + None => ("puzzle_hash", "AND asset_id IS NULL"), + }; + let sql = format!( + "SELECT * FROM coins WHERE spent_height IS NULL AND created_height IS NULL \ + AND {scope_col} IN ({ph}) {asset_clause}" + ); + let mut q = sqlx::query(&sql); + for p in puzzle_hashes { + q = q.bind(p.to_ascii_lowercase()); + } + if let Some(a) = asset_id { + q = q.bind(a.to_ascii_lowercase()); + } + let rows = q.fetch_all(&self.pool).await?; + Ok(rows + .iter() + .map(Self::coin_from_row) + .filter_map(|c| c.amount.parse::().ok()) + .sum()) + } + + /// Whether `puzzle_hash` belongs to one of the wallet's own HD derivations — the + /// `scoped_to_wallet` axis of the B.6 routing gate ([`crate::sage::routing::route`]). + /// A derivation match means the local DB is authoritative for this address once synced; + /// a non-match is an arbitrary chain address that only the fallback tier can answer + /// (#1851). `puzzle_hash` is matched case-insensitively against the stored `hex::encode` + /// form. + pub async fn derivation_exists(&self, puzzle_hash: &str) -> sqlx::Result { + let row = sqlx::query("SELECT 1 FROM derivations WHERE puzzle_hash = ? LIMIT 1") + .bind(puzzle_hash.to_ascii_lowercase()) + .fetch_optional(&self.pool) + .await?; + Ok(row.is_some()) + } + /// All coins (any spent state) for `asset_id` scoped to `puzzle_hashes`. Used by /// `get_coins`, which applies its own spent/filter modes over the returned set. pub async fn coins_scoped( diff --git a/crates/dig-wallet/src/sage/fallback.rs b/crates/dig-wallet/src/sage/fallback.rs index ece9942..0734389 100644 --- a/crates/dig-wallet/src/sage/fallback.rs +++ b/crates/dig-wallet/src/sage/fallback.rs @@ -49,6 +49,16 @@ pub trait ChainFallback: Send + Sync { async fn coin_records_by_hints(&self, hints: &[String]) -> Result>; /// A single coin by id (out-of-DB / arbitrary lookup). async fn coin_record_by_id(&self, coin_id: &str) -> Result>; + + /// Whether this fallback can actually reach a chain source. `true` for a live tier + /// ([`CoinsetFallback`]); `false` for the graceful no-network [`EmptyFallback`], whose + /// every read is a silent empty. A read that MUST consult the chain (an arbitrary, + /// non-wallet address, or a wallet address whose DB has not synced) uses this to tell + /// "chain says zero" apart from "no chain source to ask" — the difference between a + /// truthful `0` and an honest error (#1851). + fn is_live(&self) -> bool { + false + } } /// The production fallback: `chia_query::ChiaQuery` (coinset.org + peer point-reads), @@ -123,6 +133,11 @@ impl CoinsetFallback { #[async_trait] impl ChainFallback for CoinsetFallback { + /// A real coinset/peer connection: a genuinely live chain source (#1851). + fn is_live(&self) -> bool { + true + } + async fn coin_records_by_puzzle_hashes(&self, phs: &[String]) -> Result> { let phs = Self::query_hashes(phs); let records = self @@ -226,6 +241,10 @@ impl ChainFallback for EmptyFallback { async fn coin_record_by_id(&self, _coin_id: &str) -> Result> { Ok(None) } + /// No network: not a live chain source (#1851). + fn is_live(&self) -> bool { + false + } } #[cfg(test)] @@ -297,6 +316,12 @@ pub(crate) mod mock { #[async_trait] impl ChainFallback for MockFallback { + /// The test double stands in for a genuinely live chain source (unit tests that want + /// the "no chain source" path use a dedicated non-live double instead, #1851). + fn is_live(&self) -> bool { + true + } + async fn coin_records_by_puzzle_hashes(&self, phs: &[String]) -> Result> { self.calls.fetch_add(1, Ordering::SeqCst); Ok(self diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 621dad5..9595700 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -33,6 +33,71 @@ use super::types::*; use super::{actions, mint, network, offers, options, themes}; use super::{Error, Result}; +/// Which asset a [`WalletBackend::balance_for_address`] read totals (#1851). The wire form +/// is the lowercase token (`xch` / `dig`); the CAT asset id for `Dig` is sourced from +/// `digstore_chain::dig::DIG_ASSET_ID` (canonical, never hardcoded). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BalanceAsset { + /// Native chia (XCH) — no CAT asset id. + Xch, + /// The $DIG CAT. + Dig, +} + +impl BalanceAsset { + /// Parse the lowercase wire token. Returns `None` for any other value. + pub fn from_wire(s: &str) -> Option { + match s { + "xch" => Some(Self::Xch), + "dig" => Some(Self::Dig), + _ => None, + } + } + + /// The CAT asset id (bare lowercase hex) this asset scopes to, or `None` for native XCH + /// — the `asset_id` argument the DB / fallback reads take. Sourced from + /// `digstore_chain::dig::DIG_ASSET_ID` so the $DIG TAIL never drifts from the canonical + /// definition. + fn asset_id_hex(self) -> Option { + match self { + Self::Xch => None, + Self::Dig => Some(hex::encode(digstore_chain::dig::DIG_ASSET_ID)), + } + } +} + +/// The result of a [`WalletBackend::balance_for_address`] read (#1851): the confirmed + +/// pending balance for ONE address, plus the sync context so a caller can tell a +/// fully-synced figure from a still-converging one. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WalletBalanceResult { + /// Confirmed, spendable balance (unspent, on-chain) in mojos / CAT base units. + pub balance: u128, + /// Pending balance: unspent coins not yet confirmed on-chain (in-flight value). + pub pending: u128, + /// Whether the answer reflects a fully-synced view (DB caught up), vs a fallback read + /// taken while the local replica is still converging. + pub synced: bool, + /// The node's best-known chain peak height, when known. + pub peak_height: Option, +} + +/// Why a [`WalletBackend::balance_for_address`] read could not produce a figure (#1851). +/// Each variant maps to a DISTINCT wire error — never a fabricated `0`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BalanceError { + /// The address did not decode as a bech32m Chia address. + InvalidAddress, + /// No live chain source could answer a read that required one (an arbitrary, non-wallet + /// address with no live fallback attached). + NoChainSource, + /// The address is the wallet's own, but the local DB has not finished syncing and no + /// live fallback is attached, so nothing can answer yet. + NotSynced, + /// An underlying read (DB query or fallback) errored. + ReadFailed(String), +} + /// Static wallet identity + config the read surface needs (derived once at bring-up). #[derive(Debug, Clone)] pub struct WalletConfig { @@ -373,6 +438,105 @@ impl WalletBackend { Ok(self.db.is_synced().await?) } + /// The confirmed + pending balance held at ONE address, for XCH or $DIG (#1851). + /// + /// A READ-ONLY chain view — it needs only a public address, never a seed or signing key, + /// so it carries zero custody risk and answers the `control.wallet.balance` control read. + /// It reuses the EXISTING B.6 routing ([`routing::route`]): + /// + /// - **Wallet-owned address, DB synced** → the local DB is authoritative: + /// [`db::WalletDb::balance_scoped`] (confirmed) + [`db::WalletDb::pending_scoped`] + /// (unconfirmed), `synced = true`. + /// - **Otherwise** → the fallback (coinset) tier answers, `synced` reflecting the real DB + /// sync state. If no LIVE fallback is attached, the read cannot honestly answer, so it + /// returns a DISTINCT error rather than a fabricated `0`: [`BalanceError::NotSynced`] + /// for the wallet's own address (the DB would answer once synced), + /// [`BalanceError::NoChainSource`] for an arbitrary address (only a chain source could). + /// + /// `peak_height` is sourced from the node's real chain view (the DB sync-state peak), + /// never fabricated. + pub async fn balance_for_address( + &self, + address: &str, + asset: BalanceAsset, + ) -> std::result::Result { + let puzzle_hash = + normalize_ph(&decode_address(address).ok_or(BalanceError::InvalidAddress)?); + let asset_id = asset.asset_id_hex(); + + let read_err = |e: Error| BalanceError::ReadFailed(e.to_string()); + let db_synced = self.db.is_synced().await.map_err(|e| read_err(e.into()))?; + let peak_height = self + .db + .sync_state() + .await + .map_err(|e| read_err(e.into()))? + .peak_height; + let scoped = self + .db + .derivation_exists(&puzzle_hash) + .await + .map_err(|e| read_err(e.into()))?; + + match routing::route(db_synced, scoped) { + Source::Db => { + let scope = [puzzle_hash]; + let balance = self + .db + .balance_scoped(asset_id.as_deref(), &scope) + .await + .map_err(|e| read_err(e.into()))?; + let pending = self + .db + .pending_scoped(asset_id.as_deref(), &scope) + .await + .map_err(|e| read_err(e.into()))?; + Ok(WalletBalanceResult { + balance, + pending, + synced: true, + peak_height, + }) + } + Source::Fallback => { + // A fallback read must consult the chain; without a live source it cannot + // honestly answer. Distinguish "own address, still syncing" from "arbitrary + // address, no chain source" so the caller sees WHY (never a fabricated 0). + if !self.fallback.is_live() { + return Err(if scoped { + BalanceError::NotSynced + } else { + BalanceError::NoChainSource + }); + } + let phs = [puzzle_hash]; + // XCH coins sit AT the puzzle hash; CAT coins are HINTED to it. + let coins = match asset { + BalanceAsset::Xch => self.fallback.coin_records_by_puzzle_hashes(&phs).await, + BalanceAsset::Dig => self.fallback.coin_records_by_hints(&phs).await, + } + .map_err(read_err)?; + let (mut balance, mut pending) = (0u128, 0u128); + for c in &coins { + if c.spent_height.is_some() { + continue; + } + if c.created_height.is_some() { + balance += u128::from(c.amount); + } else { + pending += u128::from(c.amount); + } + } + Ok(WalletBalanceResult { + balance, + pending, + synced: db_synced, + peak_height, + }) + } + } + } + // ---- session identity scoping (#407) --------------------------------- /// Record the CLIENT's PUBLIC identity for this session (#407): the puzzle hashes / @@ -2894,8 +3058,10 @@ fn paginate(coins: Vec, offset: u32, limit: u32) -> Vec #[cfg(test)] mod tests { + use super::super::db::DerivationRow; use super::super::db::WalletDb; use super::super::fallback::mock::MockFallback; + use super::super::fallback::EmptyFallback; use super::super::fallback::FallbackCoin; use super::*; @@ -2933,6 +3099,269 @@ mod tests { } } + // ---- control.wallet.balance: balance_for_address (#1851) ------------------------------- + + /// A wallet-owned puzzle hash used across the balance tests, distinct from `test_ph` + /// so the two identity axes never coincide by accident. + fn owned_ph() -> String { + "11".repeat(32) + } + + fn owned_address() -> String { + encode_address(&owned_ph(), "xch").unwrap() + } + + /// A DB with `owned_ph` registered as a real HD derivation (the `scoped_to_wallet` axis), + /// its sync flag set, and an optional peak — the fixture for the DB-path reads. + async fn db_with_owned_derivation(synced: bool, peak: Option) -> WalletDb { + let db = WalletDb::open_in_memory().await.unwrap(); + db.upsert_derivation(&DerivationRow { + hardened: false, + index: 0, + public_key: "aa".repeat(48), + puzzle_hash: owned_ph(), + address: owned_address(), + }) + .await + .unwrap(); + db.set_initial_sync_complete(synced).await.unwrap(); + if let Some(h) = peak { + db.set_peak(h, &"cc".repeat(32)).await.unwrap(); + } + db + } + + fn coin_at_ph( + id: &str, + ph: &str, + amount: u64, + created: Option, + spent: Option, + ) -> CoinRow { + CoinRow { + coin_id: id.into(), + parent_coin_info: "pp".into(), + puzzle_hash: ph.into(), + amount: amount.to_string(), + created_height: created, + spent_height: spent, + asset_id: None, + hint: None, + created_timestamp: None, + spent_timestamp: None, + } + } + + fn fallback_coin( + id: &str, + ph: &str, + amount: u64, + created: Option, + spent: Option, + ) -> FallbackCoin { + FallbackCoin { + coin_id: id.into(), + parent_coin_info: "pp".into(), + puzzle_hash: ph.into(), + amount, + created_height: created, + spent_height: spent, + created_timestamp: None, + spent_timestamp: None, + } + } + + /// Scoped + synced ⇒ the DB path: `balance` counts ONLY confirmed unspent coins (excludes + /// the spent coin AND the not-yet-confirmed one), while `pending` reports the coin whose + /// `created_height` is NULL. The three coins have distinct states so a placement that + /// conflated them (e.g. summing all unspent into `balance`) would change the numbers. + #[tokio::test] + async fn scoped_synced_reads_db_separating_confirmed_pending_and_spent() { + let db = db_with_owned_derivation(true, Some(500)).await; + db.upsert_coins(&[ + coin_at_ph("confirmed", &owned_ph(), 100, Some(10), None), + coin_at_ph("spent", &owned_ph(), 50, Some(10), Some(20)), + coin_at_ph("pending", &owned_ph(), 7, None, None), + ]) + .await + .unwrap(); + // A live fallback is attached but MUST NOT be consulted on the DB path. + let fb = Arc::new(MockFallback::with_coins(vec![fallback_coin( + "ghost", + &owned_ph(), + 9999, + Some(1), + None, + )])); + let be = WalletBackend::new(db, fb.clone(), WalletConfig::default()); + + let r = be + .balance_for_address(&owned_address(), BalanceAsset::Xch) + .await + .unwrap(); + assert_eq!(r.balance, 100, "confirmed unspent only"); + assert_eq!(r.pending, 7, "created_height NULL coin"); + assert!(r.synced); + assert_eq!(r.peak_height, Some(500), "peak from the real chain view"); + assert_eq!(fb.call_count(), 0, "DB path never touches the fallback"); + } + + /// $DIG is scoped by the canonical CAT asset id (`digstore_chain::dig::DIG_ASSET_ID`): a + /// CAT coin hinted to the address with that asset id is counted, while an XCH coin at the + /// same address is NOT — proving the asset routing is asset-id-scoped, not address-scoped. + #[tokio::test] + async fn dig_balance_scopes_by_canonical_cat_asset_id() { + let dig = hex::encode(digstore_chain::dig::DIG_ASSET_ID); + let db = db_with_owned_derivation(true, None).await; + db.upsert_coins(&[ + CoinRow { + coin_id: "cat".into(), + parent_coin_info: "pp".into(), + puzzle_hash: "cat-inner".into(), + amount: "250".into(), + created_height: Some(10), + spent_height: None, + asset_id: Some(dig.clone()), + hint: Some(owned_ph()), + created_timestamp: None, + spent_timestamp: None, + }, + coin_at_ph("xch", &owned_ph(), 100, Some(10), None), + ]) + .await + .unwrap(); + let be = WalletBackend::new( + db, + Arc::new(MockFallback::default()), + WalletConfig::default(), + ); + + let dig_bal = be + .balance_for_address(&owned_address(), BalanceAsset::Dig) + .await + .unwrap(); + assert_eq!( + dig_bal.balance, 250, + "the $DIG CAT coin, by canonical asset id" + ); + let xch_bal = be + .balance_for_address(&owned_address(), BalanceAsset::Xch) + .await + .unwrap(); + assert_eq!(xch_bal.balance, 100, "XCH at the address, not the CAT"); + } + + /// An arbitrary (non-wallet) address routes to the LIVE fallback, and `synced` reflects the + /// DB's real `is_synced()` — asserted BOTH ways (false while syncing, true once caught up) + /// so the field cannot be a constant. + #[tokio::test] + async fn arbitrary_address_uses_fallback_and_synced_tracks_db_state() { + let arbitrary = encode_address(&"22".repeat(32), "xch").unwrap(); + let arb_ph = "22".repeat(32); + let coins = vec![ + fallback_coin("c1", &arb_ph, 42, Some(10), None), + fallback_coin("pend", &arb_ph, 5, None, None), + ]; + + for synced in [false, true] { + let db = WalletDb::open_in_memory().await.unwrap(); + db.set_initial_sync_complete(synced).await.unwrap(); + let fb = Arc::new(MockFallback::with_coins(coins.clone())); + let be = WalletBackend::new(db, fb, WalletConfig::default()); + let r = be + .balance_for_address(&arbitrary, BalanceAsset::Xch) + .await + .unwrap(); + assert_eq!(r.balance, 42, "confirmed fallback coin"); + assert_eq!(r.pending, 5, "unconfirmed fallback coin"); + assert_eq!(r.synced, synced, "synced mirrors is_synced()"); + } + } + + /// A synced, wallet-owned, EMPTY address is a SUCCESS with a zero figure — never an error. + #[tokio::test] + async fn synced_empty_address_is_zero_success_not_error() { + let db = db_with_owned_derivation(true, None).await; + let be = WalletBackend::new( + db, + Arc::new(MockFallback::default()), + WalletConfig::default(), + ); + let r = be + .balance_for_address(&owned_address(), BalanceAsset::Xch) + .await + .unwrap(); + assert_eq!(r.balance, 0); + assert_eq!(r.pending, 0); + assert!(r.synced); + } + + /// The four failure shapes are DISTINCT, so each maps to its own wire error (never a `0`): + /// invalid address, no chain source (arbitrary addr + no live fallback), not synced (own + /// addr + no live fallback), and a read failure (live fallback that errors). + #[tokio::test] + async fn failure_shapes_are_distinct() { + // Invalid address — does not decode as bech32m. + let be = WalletBackend::new( + WalletDb::open_in_memory().await.unwrap(), + Arc::new(EmptyFallback), + WalletConfig::default(), + ); + assert_eq!( + be.balance_for_address("not-an-address", BalanceAsset::Xch) + .await, + Err(BalanceError::InvalidAddress) + ); + + // No chain source: arbitrary address, DB synced, EmptyFallback (not live). + let db = WalletDb::open_in_memory().await.unwrap(); + db.set_initial_sync_complete(true).await.unwrap(); + let be = WalletBackend::new(db, Arc::new(EmptyFallback), WalletConfig::default()); + let arbitrary = encode_address(&"33".repeat(32), "xch").unwrap(); + assert_eq!( + be.balance_for_address(&arbitrary, BalanceAsset::Xch).await, + Err(BalanceError::NoChainSource) + ); + + // Not synced: the wallet's OWN address, DB not synced, EmptyFallback (not live). + let db = db_with_owned_derivation(false, None).await; + let be = WalletBackend::new(db, Arc::new(EmptyFallback), WalletConfig::default()); + assert_eq!( + be.balance_for_address(&owned_address(), BalanceAsset::Xch) + .await, + Err(BalanceError::NotSynced) + ); + + // Read failed: arbitrary address routes to a LIVE fallback that errors. + let db = WalletDb::open_in_memory().await.unwrap(); + db.set_initial_sync_complete(true).await.unwrap(); + let be = WalletBackend::new(db, Arc::new(ErringFallback), WalletConfig::default()); + assert!(matches!( + be.balance_for_address(&arbitrary, BalanceAsset::Xch).await, + Err(BalanceError::ReadFailed(_)) + )); + } + + /// A live fallback whose reads always error — for the READ_FAILED shape. + struct ErringFallback; + #[async_trait::async_trait] + impl ChainFallback for ErringFallback { + async fn coin_records_by_puzzle_hashes(&self, _: &[String]) -> Result> { + Err(Error::internal("boom")) + } + async fn coin_records_by_hints(&self, _: &[String]) -> Result> { + Err(Error::internal("boom")) + } + async fn coin_record_by_id(&self, _: &str) -> Result> { + Err(Error::internal("boom")) + } + // A live source whose reads fail — proves the READ_FAILED shape (#1851: the trait + // default is now fail-closed, so a live double must say so explicitly). + fn is_live(&self) -> bool { + true + } + } + #[tokio::test] async fn get_version_reports_crate_version() { let be = backend_with(vec![], true).await; @@ -3945,6 +4374,10 @@ mod tests { async fn coin_record_by_id(&self, _coin_id: &str) -> Result> { Ok(None) } + // Stands in for the live coinset/peer tier (#1851: the trait default is fail-closed). + fn is_live(&self) -> bool { + true + } } // A lineage source returning the CAT's parent spend (so attribution + input resolution work).