From 50b69f915d01db95536c18be0c4e01b34185505e Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 17 Aug 2026 16:21:36 -0700 Subject: [PATCH 1/4] chore(wallet): begin CAT-wide asset scoping (#3077) Co-Authored-By: Claude From 2c0e1e920155924f587b2ebfe3c83d62a8289485 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 17 Aug 2026 16:45:22 -0700 Subject: [PATCH 2/4] chore(deps): dig-node-control-interface 0.17.0 for the widened CAT wire WIP salvaged at the watchdog: dependency bump only, compiles clean. The asset-scoped coin read is not yet implemented. Co-Authored-By: Claude --- Cargo.lock | 5 +++-- crates/dig-node-service/Cargo.toml | 2 +- crates/dig-wallet/Cargo.toml | 4 ++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1120a98d..4fc403a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2239,9 +2239,9 @@ dependencies = [ [[package]] name = "dig-node-control-interface" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9717baa676e3a55c097fdc7dba6793824fdf4b1801fe115bc7820e3f19b3241" +checksum = "27ba4bac09ccec3a9214d4c83a303d9e2fd5d903058e2377768c6a0988780fd0" dependencies = [ "async-trait", "semver", @@ -2531,6 +2531,7 @@ dependencies = [ "clvmr", "dig-clvm", "dig-keystore", + "dig-node-control-interface", "dig-node-core", "digstore-chain", "digstore-core 0.19.2", diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index e457c83e..03ca4656 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -84,7 +84,7 @@ dig-node-core = { path = "../dig-node-core" } # "0.6.0" the suite went green over a `control.wallet.coinById` the contract had never heard of -- # it checked nothing about the very method the change added. A caret range keeps the pin moving # with the published catalog instead of silently narrowing what CI can see. -dig-node-control-interface = "0.16" +dig-node-control-interface = "0.17" # The OS CSPRNG for all authorization material — the control token, pairing ids/tokens # (§7), and the relay loop-probe id (`control::fill_random`). Wraps `getrandom(2)` / diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index 03dd2222..b3e9ffcf 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -45,6 +45,10 @@ clvmr = "0.14" indexmap = "2" tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "sync", "time"] } axum = "0.7" +# The PUBLISHED control-plane contract. `BalanceAsset` is re-exported from it rather than +# restated here: the wire spelling of an asset and the asset a read scopes to must be the same +# value, and two enums naming the same tokens agree only until one of them gains a variant. +dig-node-control-interface = "0.17" serde = { version = "1", features = ["derive"] } serde_json = "1" hex = "0.4" From 6dd210da3bb17578ace7cd416129c1f07fb822dd Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 17 Aug 2026 17:05:42 -0700 Subject: [PATCH 3/4] feat(wallet,control)!: scope coin reads by the requested CAT asset id BREAKING CHANGE: `BalanceAsset::Dig` is now `BalanceAsset::DIG`, a const alias for `BalanceAsset::Cat(DIG_ASSET_ID)`, and the enum carries an arbitrary CAT asset id instead of naming one of two tokens. Refs DIG-Network/dig_ecosystem#3077 --- crates/dig-node-service/src/control.rs | 62 ++++++---- crates/dig-wallet/src/sage/rpc.rs | 155 +++++++++++++++++++++---- 2 files changed, 169 insertions(+), 48 deletions(-) diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 33e37918..d215709f 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -1333,16 +1333,9 @@ async fn wallet_balance(ctx: &ControlCtx, id: Value, params: &Value) -> Value { "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:?}"), - ); + let asset = match parse_asset_param("control.wallet.balance", &id, params) { + Ok(a) => a, + Err(e) => return e, }; match ctx.wallet.balance_for_address(address, asset).await { @@ -1377,6 +1370,36 @@ async fn wallet_balance(ctx: &ControlCtx, id: Value, params: &Value) -> Value { use dig_wallet::sage::rpc::{BalanceAsset, BalanceError}; +/// Parse `params.asset` using the PUBLISHED wire form — `"xch"`, `"dig"`, or +/// `{"cat":"<64-hex asset id>"}` (dig_ecosystem#3077). +/// +/// Deserializing `dig-node-control-interface`'s own `Asset` rather than matching tokens here keeps +/// exactly one spelling of the contract in the ecosystem: a shape the crate accepts is a shape the +/// node accepts, automatically. +/// +/// # An absent asset means XCH; an UNPARSEABLE one is an error +/// +/// Those two are deliberately different. Omitting the field is a caller asking for the default +/// asset, which the contract has always said is native XCH. A field that is present and does not +/// name an asset is a caller asking for something this node cannot scope a read to — and defaulting +/// THAT to XCH is how a mistyped asset id becomes a confident balance for the wrong token. +fn parse_asset_param(method: &str, id: &Value, params: &Value) -> std::result::Result { + let Some(raw) = params.get("asset") else { + return Ok(BalanceAsset::Xch); + }; + serde_json::from_value::(raw.clone()) + .map(BalanceAsset::from) + .map_err(|e| { + control_error( + id.clone(), + ErrorCode::InvalidParams, + format!("{method} asset must be \"xch\", \"dig\", or {{\"cat\":\"<64-hex>\"}}: {e}"), + ) + }) +} + +use dig_node_control_interface::params::Asset as ControlAsset; + /// The address + asset params shared by `control.wallet.balance` and `control.wallet.coins` — a /// balance is a coins read reduced to a sum, so the two take the SAME shape (and dig-app's frozen /// `CoinsRequest` doubles as its balance request for the same reason). @@ -1394,17 +1417,7 @@ fn wallet_address_params( format!("{method} 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 Err(control_error( - id.clone(), - ErrorCode::InvalidParams, - format!("{method} asset must be \"xch\" or \"dig\", got {asset_str:?}"), - )); - }; + let asset = parse_asset_param(method, id, params)?; Ok((address.to_string(), asset)) } @@ -2210,7 +2223,10 @@ fn no_watchlist(id: Value) -> Value { /// filters by it; the read is already scoped to a single asset, so echoing the REQUESTED one is /// exactly what the coins are. fn coins_wire(r: &dig_wallet::sage::rpc::WalletCoinsResult, asset: BalanceAsset) -> Value { - let asset = asset.as_wire(); + // Serialized through the published `Asset`, so the echo is spelled exactly as the contract + // spells it — `"dig"` for $DIG, `{"cat":""}` for any other CAT — and never `null`. + let asset = serde_json::to_value(ControlAsset::from(asset)) + .expect("an Asset always serializes to a token or a one-key map"); json!({ "coins": r.coins.iter().map(|c| json!({ "coin_id": c.coin_id, @@ -2713,7 +2729,7 @@ mod tests { synced: true, peak_height: Some(5_000_000), }, - BalanceAsset::Dig, + BalanceAsset::DIG, ); assert_eq!( diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 059e3a2a..5cf35080 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -34,52 +34,66 @@ use super::routing::{self, Source}; use super::singleton::{self, LineageSource, ParentSpend}; use super::spend::{self, required_public_keys, Broadcaster, WalletSigner}; use super::types::*; +use dig_node_control_interface::params::{Asset as ControlAsset, AssetId as ControlAssetId}; 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). +/// Which asset a [`WalletBackend::balance_for_address`] read totals (#1851), widened from the +/// original XCH-or-$DIG pair to ANY CAT (dig_ecosystem#3077). +/// +/// The wire form is [`dig_node_control_interface::params::Asset`]'s — `"xch"`, `"dig"`, or +/// `{"cat":"<64-hex>"}` — and the two types convert into each other rather than each spelling it, +/// so the node cannot parse an asset the published contract does not describe. +/// +/// # Why the asset id is CARRIED, not looked up +/// +/// Every scoping decision in a read ([`Self::asset_id_hex`] for the DB tier, +/// [`Self::cat_coin_puzzle_hash`] for the hint-blind fallback tier) is derived from this id. A +/// variant that merely NAMED a token would force each derivation to resolve the name to an id +/// separately, and the read would answer a confident empty list for any token a derivation had +/// not been taught about — the silent-wrong-answer this widening exists to remove. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BalanceAsset { /// Native chia (XCH) — no CAT asset id. Xch, - /// The $DIG CAT. - Dig, + /// A CAT, named by its asset id (TAIL hash). + Cat(Bytes32), } -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, +impl From for BalanceAsset { + fn from(asset: ControlAsset) -> Self { + match asset.asset_id() { + None => Self::Xch, + Some(id) => Self::Cat(Bytes32::from(*id.as_bytes())), } } +} - /// The lowercase wire token this asset spells itself as — the SAME string [`from_wire`] - /// parses, so a result can echo the asset it was asked for without a second spelling of it. - /// - /// [`from_wire`]: BalanceAsset::from_wire - pub fn as_wire(self) -> &'static str { - match self { - Self::Xch => "xch", - Self::Dig => "dig", +impl From for ControlAsset { + fn from(asset: BalanceAsset) -> Self { + match asset { + BalanceAsset::Xch => Self::Xch, + BalanceAsset::Cat(id) => Self::Cat(ControlAssetId::new(id.to_bytes())), } } +} - /// The CAT TAIL this asset scopes to, or `None` for native XCH. +impl BalanceAsset { + /// The $DIG CAT. /// /// The single spelling of $DIG's TAIL in this module, sourced from /// `digstore_chain::dig::DIG_ASSET_ID` so it never drifts from the canonical definition. + pub const DIG: Self = Self::Cat(digstore_chain::dig::DIG_ASSET_ID); + + /// The CAT TAIL this asset scopes to, or `None` for native XCH. + /// /// [`Self::asset_id_hex`] is its hex rendering and [`Self::cat_coin_puzzle_hash`] its /// puzzle-hash rendering; all three must name the same asset or a read can scope its two /// tiers to different ones. fn cat_asset_id(self) -> Option { match self { Self::Xch => None, - Self::Dig => Some(digstore_chain::dig::DIG_ASSET_ID), + Self::Cat(id) => Some(id), } } @@ -4903,7 +4917,7 @@ mod tests { ); let dig_bal = be - .balance_for_address(&owned_address(), BalanceAsset::Dig) + .balance_for_address(&owned_address(), BalanceAsset::DIG) .await .unwrap(); assert_eq!( @@ -5033,7 +5047,7 @@ mod tests { let be = backend_over(fb).await; let r = be - .balance_for_address(&address, BalanceAsset::Dig) + .balance_for_address(&address, BalanceAsset::DIG) .await .unwrap(); assert_eq!(r.source, Source::Fallback, "the tier under test"); @@ -5076,7 +5090,7 @@ mod tests { let be = backend_over(fb).await; let r = be - .coins_for_address(&address, BalanceAsset::Dig) + .coins_for_address(&address, BalanceAsset::DIG) .await .unwrap(); let mut ids: Vec<&str> = r.coins.iter().map(|c| c.coin_id.as_str()).collect(); @@ -5088,6 +5102,97 @@ mod tests { ); } + /// The asset id of the fixture's non-$DIG CAT — the "foreign-cat" coin's TAIL. + /// + /// Named rather than inlined because the point of the widening test below is that a caller can + /// ask for THIS id, and the fixture and the request must be provably the same asset. + fn foreign_asset_id() -> Bytes32 { + Bytes32::from([0x33u8; 32]) + } + + /// **dig_ecosystem#3077 — a read for an ARBITRARY CAT returns that CAT's coins.** + /// + /// The load-bearing test of the widening. Before it, the asset type could name only XCH and + /// $DIG, so this request was inexpressible; the nearest wrong implementation — one that + /// widens the type but leaves the puzzle-hash filter derived from $DIG's id — answers this + /// with an EMPTY list, which is indistinguishable from "you hold none of that CAT". No error, + /// no warning, nothing red. + /// + /// A single-CAT fixture cannot see that defect, because $DIG's own read stays correct under + /// it. So this asks for the SECOND CAT, on a fixture where $DIG is also present and hinted to + /// the same address: a filter keyed on the wrong asset returns `[]` or returns `real-dig`, and + /// both differ from the truth. + #[tokio::test] + async fn a_fallback_read_for_an_arbitrary_cat_returns_that_cats_coins() { + let (fb, address) = hinted_multi_asset_fixture(); + let be = backend_over(fb).await; + + let r = be + .coins_for_address(&address, BalanceAsset::Cat(foreign_asset_id())) + .await + .unwrap(); + let ids: Vec<&str> = r.coins.iter().map(|c| c.coin_id.as_str()).collect(); + assert_eq!( + ids, + ["foreign-cat"], + "the requested CAT's coin, and ONLY it — not $DIG's, not the hinted XCH coin" + ); + assert_eq!(r.source, Source::Fallback, "the tier under test"); + } + + /// The honesty half of the same widening: a read for a CAT the address genuinely holds none of + /// answers an EMPTY list from a real chain consultation — it does not fail, and it does not + /// borrow another asset's coins. + /// + /// Paired with the test above deliberately. That one alone passes for an implementation that + /// filters nothing and happens to return one coin; this one alone passes for an implementation + /// that returns `[]` for every CAT. Together they pin the filter to the requested id: the same + /// fixture and the same address answer differently for two different asset ids. + #[tokio::test] + async fn a_fallback_read_for_an_unheld_cat_answers_an_honest_empty_list() { + let (fb, address) = hinted_multi_asset_fixture(); + let be = backend_over(fb).await; + + let r = be + .coins_for_address(&address, BalanceAsset::Cat(Bytes32::from([0x77u8; 32]))) + .await + .unwrap(); + assert!( + r.coins.is_empty(), + "the address holds no coin of this CAT, got {:?}", + r.coins.iter().map(|c| &c.coin_id).collect::>() + ); + assert_eq!( + r.source, + Source::Fallback, + "an empty list is an ANSWER from a consulted chain, not a suppressed read" + ); + } + + /// The wire form the widened asset travels as is the PUBLISHED one, in both directions. + /// + /// Round-tripping through `dig-node-control-interface`'s `Asset` rather than asserting + /// strings: the node must not acquire a second spelling of the contract it serves. $DIG is + /// checked explicitly because it is the one value whose two representations + /// (`BalanceAsset::DIG` and `Cat()`) must be the SAME value — if they were + /// not, a `"dig"` request and a `{"cat":"a406…"}` request would scope to different assets. + #[test] + fn the_asset_type_round_trips_through_the_published_wire_type() { + for asset in [ + BalanceAsset::Xch, + BalanceAsset::DIG, + BalanceAsset::Cat(foreign_asset_id()), + ] { + let wire: ControlAsset = asset.into(); + assert_eq!(BalanceAsset::from(wire), asset, "round trip of {asset:?}"); + } + assert_eq!( + BalanceAsset::from(ControlAsset::DIG), + BalanceAsset::DIG, + "the published $DIG id and this module's must be the same asset" + ); + } + /// **The instrument (#2233).** A coinset-served answer reports the FALLBACK tier and /// reports NOTHING about the local replica — even when that replica is fully caught up. /// From 5a5267e77365f5e51374d1f6ada0c9efe4aeac45 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 17 Aug 2026 17:16:37 -0700 Subject: [PATCH 4/4] feat(control,cli): accept an arbitrary CAT asset id on the wallet reads Adds the tagged `{"cat":"<64-hex>"}` request form to `control.wallet.balance` and `control.wallet.coins`, echoes the scoped asset back on every coin, and lets `dign wallet coins --asset <64-hex>` reach it from a terminal. SPEC \xc2\xa721 updated; workspace 0.126.0, dig-wallet 0.26.0. Refs DIG-Network/dig_ecosystem#3077 --- Cargo.lock | 4 +- Cargo.toml | 2 +- SPEC.md | 8 +-- crates/dig-node-service/src/control.rs | 77 +++++++++++++++++++++- crates/dig-node-service/src/control_cli.rs | 21 +++++- crates/dig-wallet/Cargo.toml | 2 +- crates/dig-wallet/src/sage/rpc.rs | 2 +- 7 files changed, 104 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4fc403a3..7b227f42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2310,7 +2310,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.125.0" +version = "0.126.0" dependencies = [ "async-trait", "axum", @@ -2516,7 +2516,7 @@ dependencies = [ [[package]] name = "dig-wallet" -version = "0.25.2" +version = "0.26.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index cc08dea1..7373a7aa 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.125.0" +version = "0.126.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 fed43ecb..eba29929 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1508,8 +1508,8 @@ 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), `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 the node's own peak and reports `synced: true` only while the replica is FOLLOWING the chain, so a behind-but-once-synced replica answers `synced: false` WITH its real `peak_height` rather than presenting a stale figure as current; a tier with NO observable peer height also answers `synced: false`, because nothing corroborated the figure (§18.7b); 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`, and BOTH tiers MUST scope to it. A hint is not an asset: the fallback tier finds CAT coins with `get_coin_records_by_hints`, which takes no asset id and answers with EVERY coin hinted to the address -- any CAT of any TAIL, and any plain XCH coin whose spend carried a hint memo -- so a `"fallback"` answer MUST keep only the coins sitting at that asset's CAT puzzle hash (`digstore_chain::cat::cat_puzzle_hash(owner_p2_hash, asset_id)`, the canonical curry), the exact equivalent of the DB tier's `hint IN (...) AND asset_id = ?`. Summing the raw hint answer reports a holding the address does not have, at the asked-for asset's scale rather than each coin's own: one hinted XCH coin of 10^8 mojos (`0.0001 XCH`) totals as `100000` at `$DIG`'s 3 decimals. Over-filtering is the same lie mirrored -- a real `$DIG` holder answered zero -- so the filter MUST key on that puzzle hash and nothing heuristic. A synced empty address is a SUCCESS `{balance:0, synced:true}`, never an error (and `synced` there means MEASURED-current, never merely eligible); 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.coins` | `address` (bech32m string), `asset` (`"xch"` \| `"dig"`, default `"xch"`) | `coins` (array of `{coin_id, asset, amount, parent_coin_info, puzzle_hash, created_height, spent_height}`; all hashes lowercase 64-hex unprefixed, `amount` a JSON NUMBER in base units), `source`, `synced`, `peak_height` — the tier fields carrying exactly their `control.wallet.balance` meanings (§18.7b). The UNSPENT coins at the address for the asset, i.e. the read a caller building a spend needs; a balance is this read reduced to a sum, which is why the two take identical params. It scopes to the asset by the SAME tier-agnostic rule, for the sharper reason: a coin list is spend INPUTS, so a hinted XCH or foreign-CAT coin served as a `$DIG` coin is a spend built on inputs of the wrong asset. Coins seen only in the mempool are INCLUDED with `created_height: null`, so the caller decides what is spendable for its purpose rather than the node hiding one. `coins: []` MUST mean a chain WAS consulted and the address holds nothing; every way of failing to consult one is a DISTINCT error (`WALLET_NO_CHAIN_SOURCE`/`WALLET_NOT_SYNCED`/`WALLET_READ_FAILED`/`WALLET_RATE_LIMITED`, §10), NEVER an empty list — an empty list would tell a holder of funds that they hold none, and a spend built on it refuses with an untrue shortfall. OPEN read, same global fallback rate bound as the balance. `INVALID_PARAMS` on a missing/malformed `address` or a bad `asset`. | +| `control.wallet.balance` | `address` (bech32m string), `asset` (`"xch"` \| `"dig"` \| `{"cat":"<64-hex asset id>"}`, 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 the node's own peak and reports `synced: true` only while the replica is FOLLOWING the chain, so a behind-but-once-synced replica answers `synced: false` WITH its real `peak_height` rather than presenting a stale figure as current; a tier with NO observable peer height also answers `synced: false`, because nothing corroborated the figure (§18.7b); 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. A CAT scopes by the asset id the REQUEST named -- any CAT, not only `$DIG` -- and BOTH tiers MUST scope to that id. `"dig"` is the canonical id `digstore_chain::dig::DIG_ASSET_ID` spelled as a token, and `{"cat":""}` MUST mean the same asset. Every scoping hash a tier derives MUST be derived FROM the requested id: a filter keyed to a fixed asset answers every other CAT an EMPTY list, which is indistinguishable from holding none of it -- a silent wrong answer with nothing to observe. An `asset` that is PRESENT and does not parse is `INVALID_PARAMS`; it MUST NOT default to `"xch"`, because a mistyped asset id would then read as a balance for the wrong token. An OMITTED `asset` is the documented `"xch"` default. A hint is not an asset: the fallback tier finds CAT coins with `get_coin_records_by_hints`, which takes no asset id and answers with EVERY coin hinted to the address -- any CAT of any TAIL, and any plain XCH coin whose spend carried a hint memo -- so a `"fallback"` answer MUST keep only the coins sitting at that asset's CAT puzzle hash (`digstore_chain::cat::cat_puzzle_hash(owner_p2_hash, asset_id)`, the canonical curry), the exact equivalent of the DB tier's `hint IN (...) AND asset_id = ?`. Summing the raw hint answer reports a holding the address does not have, at the asked-for asset's scale rather than each coin's own: one hinted XCH coin of 10^8 mojos (`0.0001 XCH`) totals as `100000` at `$DIG`'s 3 decimals. Over-filtering is the same lie mirrored -- a real `$DIG` holder answered zero -- so the filter MUST key on that puzzle hash and nothing heuristic. A synced empty address is a SUCCESS `{balance:0, synced:true}`, never an error (and `synced` there means MEASURED-current, never merely eligible); 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.coins` | `address` (bech32m string), `asset` (`"xch"` \| `"dig"` \| `{"cat":"<64-hex asset id>"}`, default `"xch"`) | `coins` (array of `{coin_id, asset, amount, parent_coin_info, puzzle_hash, created_height, spent_height}`; all hashes lowercase 64-hex unprefixed, `amount` a JSON NUMBER in base units), `source`, `synced`, `peak_height` — the tier fields carrying exactly their `control.wallet.balance` meanings (§18.7b). The UNSPENT coins at the address for the asset, i.e. the read a caller building a spend needs; a balance is this read reduced to a sum, which is why the two take identical params. It scopes to the asset by the SAME tier-agnostic rule, for the sharper reason: a coin list is spend INPUTS, so a hinted XCH or foreign-CAT coin served as a `$DIG` coin is a spend built on inputs of the wrong asset. Coins seen only in the mempool are INCLUDED with `created_height: null`, so the caller decides what is spendable for its purpose rather than the node hiding one. `coins: []` MUST mean a chain WAS consulted and the address holds nothing; every way of failing to consult one is a DISTINCT error (`WALLET_NO_CHAIN_SOURCE`/`WALLET_NOT_SYNCED`/`WALLET_READ_FAILED`/`WALLET_RATE_LIMITED`, §10), NEVER an empty list — an empty list would tell a holder of funds that they hold none, and a spend built on it refuses with an untrue shortfall. OPEN read, same global fallback rate bound as the balance. `INVALID_PARAMS` on a missing/malformed `address` or a bad `asset`. | | `control.wallet.coinById` | `coin_id` (64 lowercase-hex, an optional `0x` prefix TOLERATED and normalized away) | `coin` (`{coin_id, asset, amount, parent_coin_info, puzzle_hash, created_height, spent_height}` or `null`), `source`, `synced`, `peak_height` — the tier fields carrying exactly their `control.wallet.balance` meanings (§18.7b); the tier fields MUST describe WHAT ANSWERED THIS READ. Where the local replica HOLDS the named coin and is authoritative for the set it follows (the same `control.wallet.balance` eligibility test, §18.7b), the node MUST answer from the replica: `source: "db"`, `peak_height` the replica's own peak, and `synced` MEASURED against the peers' announced peak rather than assumed — a replica that completed a catch-up and then fell behind still serves the coin, with its real peak, labelled stale. A replica MISS MUST fall through to the chain tier and be reported as such (`source: "fallback"`, `synced: false`, `peak_height: null`); it MUST NEVER be served as an absence, because the replica is populated only from this node's own subscriptions, so a miss means "this node does not watch that coin", which is NOT absence. A node MUST NOT report `source: "fallback"`, `synced: false` for a coin it holds: a warrant no read can ever carry turns every consumer-side freshness guard into an unconditional refusal, which ends a mint watch in "the chain could not be reached" on a healthy node. ONE coin by its own id, SPENT OR UNSPENT — the read a caller polling a spend needs and `control.wallet.coins` structurally cannot give: a created DID coin sits at nobody's wallet address, and a spent funding coin is gone from every unspent list. `asset` in the record is ALWAYS `null`: a coin id alone does not reveal whether a coin is XCH, a CAT or a singleton — that needs the puzzle, which this read never inspects — so naming one would assert a classification the node did not verify. A returned record MUST be bound to the id asked for: a coin id is self-certifying (`SHA256(parent ‖ puzzle_hash ‖ amount)`), so a source that answers with a DIFFERENT coin is a `WALLET_READ_FAILED` (§10) — never that coin's record, and never `coin: null`. `coin: null` MUST mean a chain source ANSWERED and reported no such coin; every way of failing to get an answer is a DISTINCT error (`WALLET_NO_CHAIN_SOURCE`/`WALLET_READ_FAILED`/`WALLET_RATE_LIMITED`, §10), NEVER a `null` — a `null` for an outage would tell a caller polling a mint that its coin does not exist, so a pending mint reads as awaiting forever. A caller MUST treat `null` as "not seen yet" and keep polling, not as "never happened". OPEN read (no token), same global fallback rate bound as the balance. `INVALID_PARAMS` on a missing/malformed `coin_id`, refused BEFORE any network call — an unanswerable question and a chain that answered "no" must never wear the same shape; the well-formedness rule is `dig-node-control-interface`'s own `WalletCoinByIdParams::validated()`, consumed rather than restated. | | `control.wallet.coinSpend` | `coin_id` (64 lowercase-hex, an optional `0x` prefix TOLERATED and normalized away) | `spend` (`{coin, puzzle_reveal, solution}` or `null`), `source`, `synced`, `peak_height` -- the tier fields carrying exactly their `control.wallet.coinById` meanings, and here always `"fallback"` / `false` / `null`: the local replica stores coin records, not spends, so it can never produce this answer. THE SPEND THAT SPENT ONE COIN, named by that coin's own id (a spend has no id of its own on chain). A coin record carries a puzzle HASH and says only that a coin is gone; the puzzle REVEAL and the solution exist only here, and they are what a caller reconstructing a lineage -- following a dig-profile's DID singleton forward -- needs. `coin` is the full record shape `control.wallet.coinById` returns, with `asset` ALWAYS `null` (this read classifies nothing) and `spent_height` ALWAYS non-null (a spend of a coin nothing calls spent is a contradiction; the node MUST fail closed rather than emit one). The node MUST verify that `puzzle_reveal` tree-hashes to `coin.puzzle_hash` and MUST refuse -- `WALLET_READ_FAILED` (§10) -- when it does not or will not parse: the reveal comes from an unauthenticated peer, a puzzle hash IS the reveal's CLVM tree hash, so the lie is locally detectable and a caller would otherwise curry a forged program into the spend it signs. The returned spend MUST be bound to the id asked for, by the same self-certifying coin-id recomputation `control.wallet.coinById` requires. `spend: null` MUST mean a chain source ANSWERED and holds no spend of that coin -- it is UNSPENT, or unknown; distinguishing those two is `control.wallet.coinById`'s job. Every way of failing to get an answer is a DISTINCT error, NEVER `null`: a caller walking a lineage reads "no spend" as *this is the tip* and stops, so a failure disguised as absence yields a spend built against a superseded singleton, and a mint poll reads it as "my funding coin is still there" and funds the same mint twice. OPEN read (no token), same global fallback rate bound. `INVALID_PARAMS` on a missing/malformed `coin_id`, refused BEFORE any network call; the rule is `dig-node-control-interface`'s own `WalletCoinSpendParams::validated()`, consumed rather than restated. | | `control.wallet.coinsByParent` | `parent_coin_id` (64 lowercase-hex, `0x` TOLERATED), optional `after_coin_id` (same rule), optional `limit` (1..=1000, default 100) | `coins` (array of the `control.wallet.coinById` record shape), `complete`, `cursor`, `source`, `synced`, `peak_height`. ONE PAGE of the DIRECT children created by spending the named parent. ONE HOP, never a walk: the node MUST NOT recurse -- a transitive walk over caller-supplied input is unbounded work the caller cannot bound, and a partial walk returned as complete is a lineage with a silent hole in it. A caller composes hops itself, pairing this with `control.wallet.coinSpend`. Children MUST be returned in ASCENDING `coin_id` order and that order MUST be stable across the pages of one walk, because `after_coin_id` means *strictly after this id in that order* and without a fixed order a cursor names no position (a walk would repeat some children and skip others). `complete` states whether the page is the WHOLE child set and MUST be derived from whether further children EXIST -- never from whether the page filled: the two differ exactly when the child count is an integer multiple of `limit`, where the second declares a truncated page whole and ends a lineage walk one hop early while looking finished. `cursor` is the LAST child in the page (the id the caller was handed), or `null` for an empty page; a node MUST NOT emit `complete: false` with `cursor: null`, which leaves a caller with no way to make progress. An out-of-range `limit` is REFUSED as `INVALID_PARAMS`, never clamped: the page boundary is what the caller resumes from, so a silently shrunk page hands back a cursor for a position the caller never asked about. Every record MUST report `asset: null` (naming a coin by its parent classifies nothing). Every child MUST name the requested parent; a source that returns one that does not fails the WHOLE read (`WALLET_READ_FAILED`, §10) rather than having the row filtered out. `coins: []` MUST mean a chain ANSWERED and the parent created no children it knows of -- typically it is unspent; every way of failing to consult a chain is a DISTINCT error, never an empty page, because an empty page reads as *that spend created nothing*. OPEN read (no token), same global fallback rate bound. `INVALID_PARAMS` on a missing/malformed id or an illegal `limit`, refused BEFORE any network call; the rules are `dig-node-control-interface`'s own `WalletCoinsByParentParams::validated()`. | @@ -2308,8 +2308,8 @@ on-disk master token = local-machine control), never an unauthenticated backdoor - `stores [list]` → `control.hostedStores.list`; `stores pin|unpin|status ` → `control.hostedStores.pin|unpin|status`. - `sync [status]` → `control.sync.status`; `sync trigger ` → `control.sync.trigger`. -- `wallet balance
[--asset xch|dig]` → `control.wallet.balance`; - `wallet coins
[--asset xch|dig]` → `control.wallet.coins`; +- `wallet balance
[--asset xch|dig|<64-hex asset id>]` → `control.wallet.balance`; + `wallet coins
[--asset xch|dig|<64-hex asset id>]` → `control.wallet.coins`; `wallet coin-by-id ` → `control.wallet.coinById`; `wallet coin-spend ` → `control.wallet.coinSpend`; `wallet coins-by-parent [--after-coin-id ] [--limit ]` → diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index d215709f..9ed605dc 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -1383,7 +1383,11 @@ use dig_wallet::sage::rpc::{BalanceAsset, BalanceError}; /// asset, which the contract has always said is native XCH. A field that is present and does not /// name an asset is a caller asking for something this node cannot scope a read to — and defaulting /// THAT to XCH is how a mistyped asset id becomes a confident balance for the wrong token. -fn parse_asset_param(method: &str, id: &Value, params: &Value) -> std::result::Result { +fn parse_asset_param( + method: &str, + id: &Value, + params: &Value, +) -> std::result::Result { let Some(raw) = params.get("asset") else { return Ok(BalanceAsset::Xch); }; @@ -1393,7 +1397,9 @@ fn parse_asset_param(method: &str, id: &Value, params: &Value) -> std::result::R control_error( id.clone(), ErrorCode::InvalidParams, - format!("{method} asset must be \"xch\", \"dig\", or {{\"cat\":\"<64-hex>\"}}: {e}"), + format!( + "{method} asset must be \"xch\", \"dig\", or {{\"cat\":\"<64-hex>\"}}: {e}" + ), ) }) } @@ -2752,6 +2758,73 @@ mod tests { ); } + /// **dig_ecosystem#3077 — the control plane accepts an ARBITRARY CAT and ECHOES it back.** + /// + /// Two properties in one test because they are one contract: the tagged request form must + /// PARSE, and the answer must name the CAT it was scoped to rather than falling back to a + /// token the node happens to know. The echo is the only place a caller can see WHICH asset the + /// node read, so a `"dig"` or a `null` here would make an arbitrary-CAT read unverifiable. + /// + /// Uses a non-$DIG id deliberately: $DIG round-trips through a legacy token and so exercises + /// neither the tagged parse nor the tagged emission. + #[test] + fn an_arbitrary_cat_parses_from_the_wire_and_is_echoed_onto_every_coin() { + use dig_wallet::sage::routing::Source; + use dig_wallet::sage::rpc::{WalletCoin, WalletCoinsResult}; + + let id = "11".repeat(32); + let asset = parse_asset_param( + "control.wallet.coins", + &json!(1), + &json!({ "address": "xch1…", "asset": { "cat": id } }), + ) + .expect("the published tagged form parses"); + assert_ne!(asset, BalanceAsset::DIG, "a CAT that is not $DIG"); + + let wire = coins_wire( + &WalletCoinsResult { + coins: vec![WalletCoin { + coin_id: "aa".repeat(32), + parent_coin_info: "bb".repeat(32), + puzzle_hash: "cc".repeat(32), + amount: 1, + created_height: Some(1), + spent_height: None, + }], + source: Source::Fallback, + synced: false, + peak_height: None, + }, + asset, + ); + assert_eq!( + wire["coins"][0]["asset"], + json!({ "cat": id }), + "the coin names the CAT the read was scoped to" + ); + } + + /// An `asset` that is PRESENT and names nothing is refused; an ABSENT one defaults to XCH. + /// + /// The pair matters more than either half. A parser that defaulted an unparseable asset to XCH + /// would satisfy the absent case identically, and would turn a mistyped asset id into a + /// confident balance for the wrong token — so the control is the omitted field, not a second + /// bad value. + #[test] + fn an_unparseable_asset_is_refused_while_an_absent_one_defaults_to_xch() { + for bad in [json!("dgi"), json!({ "cat": "nope" }), json!(7)] { + assert!( + parse_asset_param("m", &json!(1), &json!({ "asset": bad })).is_err(), + "{bad} must not name an asset" + ); + } + assert_eq!( + parse_asset_param("m", &json!(1), &json!({ "address": "xch1…" })), + Ok(BalanceAsset::Xch), + "an omitted asset is the documented XCH default" + ); + } + /// **`coins_wire` REPORTS a coin's `spent_height`; it does not assert one.** /// /// The mapper used to emit a hardcoded `null` here, justified by "every coin in an diff --git a/crates/dig-node-service/src/control_cli.rs b/crates/dig-node-service/src/control_cli.rs index 560e8a38..771a05fb 100644 --- a/crates/dig-node-service/src/control_cli.rs +++ b/crates/dig-node-service/src/control_cli.rs @@ -171,6 +171,25 @@ impl ControlAction { /// Public so a test can assert that a parsed command line's operands actually reach the wire, /// not merely that it selected the right method (see `entrypoint`'s parser tests). pub fn wire_params(&self) -> Value { + /// A `--asset` operand as the wire form the control plane parses (dig_ecosystem#3077). + /// + /// `xch` and `dig` travel as themselves. A bare 64-hex asset id becomes the tagged + /// `{"cat":""}` form, so a person can read an ARBITRARY CAT from the command line — + /// without that, the widened wire would be reachable only by a program. + /// + /// Anything else is forwarded UNCHANGED, to be refused by the node's own parser. This CLI + /// deliberately does not decide what an asset is: a second, laxer opinion here is how a + /// typo becomes a read of the wrong token. + fn asset_to_wire(asset: &str) -> Value { + let looks_like_an_asset_id = + asset.len() == 64 && asset.bytes().all(|b| b.is_ascii_hexdigit()); + if looks_like_an_asset_id { + json!({ "cat": asset }) + } else { + Value::String(asset.to_string()) + } + } + match self { ControlAction::ConfigSetUpstream { url } => json!({ "upstream": url }), ControlAction::CacheSetCap { bytes } => json!({ "cap_bytes": bytes }), @@ -180,7 +199,7 @@ impl ControlAction { | ControlAction::SyncTrigger { store } => json!({ "store": store }), ControlAction::WalletBalance { address, asset } | ControlAction::WalletCoins { address, asset } => { - json!({ "address": address, "asset": asset }) + json!({ "address": address, "asset": asset_to_wire(asset) }) } ControlAction::WalletCoinById { coin_id } | ControlAction::WalletCoinSpend { coin_id } => json!({ "coin_id": coin_id }), diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index b3e9ffcf..f1dfd8a8 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-wallet" -version = "0.25.2" +version = "0.26.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/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 5cf35080..99b98371 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -34,9 +34,9 @@ use super::routing::{self, Source}; use super::singleton::{self, LineageSource, ParentSpend}; use super::spend::{self, required_public_keys, Broadcaster, WalletSigner}; use super::types::*; -use dig_node_control_interface::params::{Asset as ControlAsset, AssetId as ControlAssetId}; use super::{actions, mint, network, offers, options, themes}; use super::{Error, Result}; +use dig_node_control_interface::params::{Asset as ControlAsset, AssetId as ControlAssetId}; /// Which asset a [`WalletBackend::balance_for_address`] read totals (#1851), widened from the /// original XCH-or-$DIG pair to ANY CAT (dig_ecosystem#3077).