Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 |
|---|---|---|---|
Expand All @@ -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.
Expand Down
152 changes: 152 additions & 0 deletions crates/dig-node-service/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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` +
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<std::sync::Mutex<crate::pairing::PendingPairings>>,
/// 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<dig_wallet::sage::rpc::WalletBackend>,
}

/// Dispatch a single authorized CONTROL method. The caller has ALREADY enforced the
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));
}
}
37 changes: 37 additions & 0 deletions crates/dig-node-service/src/control_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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",
Expand All @@ -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 } => {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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),
}
Expand Down Expand Up @@ -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).
Expand Down
Loading
Loading