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.100.2"
version = "0.101.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
35 changes: 34 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1466,7 +1466,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. This is an OPEN read (`is_open_control_read`, no token); the cheap local-DB fast path is unbounded, but the EXPENSIVE coinset-fallback leg is subject to a GLOBAL token-bucket rate bound (defense-in-depth against an open-read amplification/oracle sweep — #1957): a burst of arbitrary-address fallback reads beyond the bound is refused with `WALLET_RATE_LIMITED` (§10), while any single honest read (DB fast path or one fallback) always succeeds. `$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 read-failure shapes are DISTINCT errors `WALLET_NO_CHAIN_SOURCE`/`WALLET_NOT_SYNCED`/`WALLET_READ_FAILED`/`WALLET_RATE_LIMITED` (§10), never a fabricated `0`. `INVALID_PARAMS` on a missing/malformed `address` or a bad `asset`. |
| `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), `source` (`"db"` \| `"fallback"` — which tier produced the figure, §18.7b), `synced` (bool), `peak_height` (`u32` 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. Per §18.7b, `source`/`synced`/`peak_height` describe the TIER that answered: a `"db"` answer reports `synced: true` and the node's own peak; a `"fallback"` answer reports `synced: false` and `peak_height: null`. This is an OPEN read (`is_open_control_read`, no token); the cheap local-DB fast path is unbounded, but the EXPENSIVE coinset-fallback leg is subject to a GLOBAL token-bucket rate bound (defense-in-depth against an open-read amplification/oracle sweep — #1957): a burst of arbitrary-address fallback reads beyond the bound is refused with `WALLET_RATE_LIMITED` (§10), while any single honest read (DB fast path or one fallback) always succeeds. `$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 read-failure shapes are DISTINCT errors `WALLET_NO_CHAIN_SOURCE`/`WALLET_NOT_SYNCED`/`WALLET_READ_FAILED`/`WALLET_RATE_LIMITED` (§10), never a fabricated `0`. `INVALID_PARAMS` on a missing/malformed `address` or a bad `asset`. |
Comment thread
MichaelTaylor3d marked this conversation as resolved.
| `control.peers.ping` | `peer` (a 64-hex `peer_id`, or a dialable `host:port` with IPv6 bracketed), `peer_id` (OPTIONAL 64-hex — pins the identity the presented certificate MUST derive) | The connection-ladder report — see §7.4a. `INVALID_PARAMS` on a missing/blank `peer`; `CONTROL_ERROR` when no peer network is running; `PEER_PING_REFUSED` (§10) when the anti-amplification gate refuses before dialing. |

### 7.4a. `control.peers.ping` — the connection-ladder diagnostic
Expand Down Expand Up @@ -3707,6 +3707,39 @@ hashes/addresses).
read as NOT synced and never as a synced-zero. `selectable_balance` is the identity-scoped unspent XCH
balance (0 when not tracking).

18.7b. **Tier disclosure — the reported state describes the ANSWER, not the node (#2233).** Every
wallet read that chooses a source per the §18.7 routing table MUST disclose the tier that actually
answered, and MUST derive every freshness field from that tier.

- **`source` is additive and REQUIRED on the result.** `control.wallet.balance` returns
`source: "db" | "fallback"` alongside its figures. `"db"` means the node's own chain replica
produced the figure; `"fallback"` means a third-party coinset HTTP oracle did, which additionally
means the queried address WAS DISCLOSED off-node — a fact a caller on a metered or private
connection has a legitimate interest in. The field is additive per §5.1: a consumer that does not
read it parses unchanged.
- **`synced` and `peak_height` are properties of the tier.** A `"db"` answer reports `synced: true`
and the replica's own peak. A `"fallback"` answer reports `synced: false` and `peak_height: null`,
**regardless of the local DB's state** — the DB neither produced that figure nor bounds its
freshness, so its flag and peak say nothing about it. Implementations MUST NOT read those two
fields outside the tier decision.
- **Rationale — this is the falsifiability instrument for §18.6.** A success criterion phrased as a
flag value rather than as the path taken is satisfiable with the goal unmet: once the §18.6 sync
loop sets `initial_sync_complete`, a read still served by the oracle would report itself as a
synced local read. Acceptance for any sync work MUST name the `source` tier, never the `synced`
flag alone.
- **Operator visibility.** The routing branch emits a `tracing` event carrying `tier=db|fallback`,
so `dig-node.jsonl` records the same tier the wire reports. Diagnostics go through `tracing`,
never stderr (a Windows service discards it).

**The FALLBACK tier MUST be logged at a level a stock node actually emits.** `dig-logging`'s
baked-in default is `info` and a default install sets none of the overrides, so a `debug!` here is
invisible in the field — which would make the sentence above false on every stock node, and would
let an acceptance run reading `dig-node.jsonl` mistake silence for "no fallback occurred". Fallback
is therefore `info`; it is the exceptional path and it means the read was disclosed to a
third-party oracle. The DB tier stays `debug`: once the §18.6 sync loop lands it is the ordinary
path, and logging every local read at `info` would turn an OPEN unauthenticated loopback endpoint
into a log-volume lever.

18.8. **Method surface — reads (served).** `login`, `logout`, `get_version`,
`get_sync_status`, `check_address`, `get_derivations`, `get_are_coins_spendable`,
`get_spendable_coin_count`, `get_coins`, `get_coins_by_ids`, `get_cats`, `get_all_cats`, `get_token`,
Expand Down
49 changes: 47 additions & 2 deletions crates/dig-node-service/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1197,6 +1197,7 @@ 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),
"source": r.source,
Comment thread
MichaelTaylor3d marked this conversation as resolved.
"synced": r.synced,
"peak_height": r.peak_height,
})
Expand All @@ -1208,7 +1209,10 @@ fn balance_wire(r: &dig_wallet::sage::rpc::WalletBalanceResult) -> Value {
/// 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
/// `{ balance, pending, source, synced, peak_height }`, where `source` is `"db"` (the node's own
/// chain replica) or `"fallback"` (a third-party coinset oracle — the address was disclosed
/// off-node), and `synced`/`peak_height` describe THAT tier (#2233).
/// 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 {
Expand Down Expand Up @@ -1816,6 +1820,7 @@ mod tests {
/// against the printed JSON.
#[test]
fn balance_wire_emits_numeric_amounts_matching_app_contract() {
use dig_wallet::sage::routing::Source;
use dig_wallet::sage::rpc::WalletBalanceResult;

#[derive(serde::Deserialize)]
Expand All @@ -1826,6 +1831,7 @@ mod tests {
let r = WalletBalanceResult {
balance: 12_345,
pending: 6,
source: Source::Db,
synced: true,
peak_height: Some(42),
};
Expand All @@ -1834,7 +1840,10 @@ mod tests {
// Golden shape: numeric, not string.
assert_eq!(
emitted,
json!({"balance": 12345u64, "pending": 6u64, "synced": true, "peak_height": 42}),
json!({
"balance": 12345u64, "pending": 6u64,
"source": "db", "synced": true, "peak_height": 42
}),
);
assert!(
emitted["balance"].is_number(),
Expand All @@ -1858,15 +1867,51 @@ mod tests {
/// overflow.
#[test]
fn balance_wire_saturates_u128_overflow_to_u64_max() {
use dig_wallet::sage::routing::Source;
use dig_wallet::sage::rpc::WalletBalanceResult;

let r = WalletBalanceResult {
balance: u128::from(u64::MAX) + 1,
pending: 0,
source: Source::Fallback,
synced: false,
peak_height: None,
};
let emitted = balance_wire(&r);
assert_eq!(emitted["balance"], json!(u64::MAX));
}

/// (#2233) The tier reaches the WIRE as the lowercase token a consumer keys on, for BOTH
/// tiers — so a mapper that dropped the field, or emitted the Rust variant name (`"Db"`),
/// fails here rather than at a consumer.
///
/// The `source` field is ADDITIVE (§5.1): the same test asserts a consumer struct that
/// does not know about it still deserializes, so ignoring it cannot break a caller.
#[test]
fn balance_wire_discloses_the_answering_tier_additively() {
use dig_wallet::sage::routing::Source;
use dig_wallet::sage::rpc::WalletBalanceResult;

#[derive(serde::Deserialize)]
struct OldConsumer {
balance: u64,
synced: bool,
}

for (source, wire) in [(Source::Db, "db"), (Source::Fallback, "fallback")] {
let emitted = balance_wire(&WalletBalanceResult {
balance: 1,
pending: 0,
source,
synced: source == Source::Db,
peak_height: None,
});
assert_eq!(emitted["source"], json!(wire));

let old: OldConsumer = serde_json::from_value(emitted)
.expect("a consumer unaware of `source` must still parse");
assert_eq!(old.balance, 1);
assert_eq!(old.synced, source == Source::Db);
}
}
}
2 changes: 1 addition & 1 deletion crates/dig-wallet/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "dig-wallet"
version = "0.12.2"
version = "0.13.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."
Expand Down
30 changes: 29 additions & 1 deletion crates/dig-wallet/src/sage/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,32 @@
//! has a single, auditable definition; the RPC layer calls it once per wallet-data read.

/// Where a wallet-data read is served from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
///
/// This is also the WIRE spelling of the tier (`"db"` / `"fallback"`), reported on every
/// read result that makes a tier choice (#2233). One definition serves both the routing
/// decision and its disclosure, so the reported tier cannot drift from the tier taken.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Source {
/// The local SQLite wallet DB (peer-maintained, design B.3/B.6).
Db,
/// The `chia-query`/coinset.org fallback tier (design B.5).
Fallback,
}

impl Source {
/// The wire/log spelling of this tier — the SAME string [`serde::Serialize`] emits.
///
/// Used for the `tier` field on the routing `tracing` event, so a log line and the JSON
/// result a caller reads always name the tier identically.
pub fn as_wire(self) -> &'static str {
match self {
Self::Db => "db",
Self::Fallback => "fallback",
}
}
}

/// Select the source for a wallet-data read given the two B.6 axes.
///
/// - `db_synced`: has the initial subscription catch-up completed
Expand Down Expand Up @@ -59,4 +77,14 @@ mod tests {
assert_eq!(route(true, false), Source::Fallback);
assert_eq!(route(false, false), Source::Fallback);
}

/// The wire spelling and the log spelling are the SAME string, pinned literally so a
/// rename of the Rust variant cannot silently change what a consumer parses (#2233).
#[test]
fn tier_serializes_and_logs_as_the_same_lowercase_wire_string() {
for (src, wire) in [(Source::Db, "db"), (Source::Fallback, "fallback")] {
assert_eq!(serde_json::to_value(src).unwrap(), serde_json::json!(wire));
assert_eq!(src.as_wire(), wire);
}
}
}
Loading
Loading