diff --git a/Cargo.lock b/Cargo.lock index cdf15cf..ea0d4d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ dependencies = [ [[package]] name = "dig-node-control-interface" -version = "0.2.0" +version = "0.3.0" dependencies = [ "async-trait", "futures", diff --git a/Cargo.toml b/Cargo.toml index 1e61e86..747888a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ # is designed, matching the sibling dig--protocol crates' bootstrap order. [package] name = "dig-node-control-interface" -version = "0.2.0" +version = "0.3.0" edition = "2021" rust-version = "1.75.0" license = "Apache-2.0 OR MIT" diff --git a/SPEC.md b/SPEC.md index 7466099..7de192e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -93,6 +93,7 @@ master token specifically; `Routing` = how the node resolves it (`owned` by the | `control.subscribe` | yes | delegated | `{store_id:string}` | `{subscribed, added, store_id}` | | `control.unsubscribe` | yes | delegated | `{store_id:string}` | `{subscribed, removed, store_id}` | | `control.listSubscriptions` | yes | delegated | — | `{subscriptions:[string], count}` | +| `control.wallet.balance` | yes | delegated | `{address:string, asset:"xch"\|"dig"}` | `{balance, pending, synced, peak_height}` | | `pairing.request` | no | open | `{client_name:string}` | `{pairing_id, pairing_code, expires_ms}` | | `pairing.poll` | no | open | `{pairing_id:string}` | `{status, token?}` | @@ -110,6 +111,14 @@ master token specifically; `Routing` = how the node resolves it (`owned` by the - **`CapsuleEntry`**: `{capsule:"storeId:root", root:string, size_bytes:u64, last_used_unix_ms:u64}`. - **`pairing.poll` token**: the `token` field MUST be omitted while `status` is not `approved`, and present exactly once after approval. +- **`WalletBalanceResult`**: `{balance:u64, pending:u64, synced:bool, peak_height:u32|null}`. A + READ-only chain read over the loopback control plane — it reports state, never moves funds. `balance` + is the CONFIRMED spendable amount in the asset's base unit (mojos for XCH, base units for DIG); + `pending` is incoming-unconfirmed; `synced:false` means the figures are STALE; `peak_height` is the + block height the figures reflect (present as `null`, never omitted, when the node has no height yet). + The `asset` request field is the lowercase wire token `"xch"`/`"dig"`. This result is a strict + SUPERSET of dig-app's `BalanceResponse {balance}`: a consumer reading only `{balance}` deserializes + it losslessly (unknown fields ignored), which is the no-consumer-change guarantee pinned by a KAT. Proxied results (`control.updater.*`, `control.pairing.list`, `control.peerStatus`) carry the underlying source's shape verbatim and are modelled as an opaque JSON value; consumers MUST NOT freeze diff --git a/src/kats.rs b/src/kats.rs index 30924d5..8cd27de 100644 --- a/src/kats.rs +++ b/src/kats.rs @@ -109,6 +109,13 @@ fn golden_request_vectors() { }, json!({"jsonrpc":"2.0","id":1,"method":"pairing.request","params":{"client_name":"DIG extension"}}), ); + assert_request( + &WalletBalanceParams { + address: "xch1exampleaddr".into(), + asset: Asset::Dig, + }, + json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.balance","params":{"address":"xch1exampleaddr","asset":"dig"}}), + ); } #[test] @@ -156,6 +163,44 @@ fn golden_response_result_vectors_are_byte_stable() { assert_result_round_trips::(json!({ "status": "approved", "token": "deadbeef" })); + assert_result_round_trips::(json!({ + "balance": 1234u64, "pending": 0u64, "synced": true, "peak_height": 5000000u32 + })); + // `peak_height` is present as `null` (never omitted) when the node has no height yet. + assert_result_round_trips::(json!({ + "balance": 0u64, "pending": 7u64, "synced": false, "peak_height": null + })); +} + +/// The "no dig-app code change" guarantee, pinned: the node's richer `WalletBalanceResult` is a +/// strict SUPERSET of dig-app's frozen `BalanceResponse { balance }`, so dig-app deserializes the +/// node's payload losslessly (its struct does not deny unknown fields) and reads the confirmed +/// balance. This mirrors dig-app's `dig-app-core::wallet::engine::BalanceResponse` byte-for-byte. +#[test] +fn node_balance_superset_is_readable_by_dig_apps_balance_struct() { + /// Byte-identical mirror of dig-app's frozen `BalanceResponse` — NO `deny_unknown_fields`, so the + /// node's extra fields (`pending`/`synced`/`peak_height`) are ignored, not rejected. + #[derive(serde::Deserialize)] + struct DigAppBalanceResponse { + balance: u64, + } + + // The node emits the full superset... + let node_payload = serde_json::to_value(results::WalletBalanceResult { + balance: 9_999, + pending: 42, + synced: true, + peak_height: Some(6_123_456), + }) + .unwrap(); + + // ...and dig-app's `{balance}` struct reads it without any code change on dig-app's side. + let app: DigAppBalanceResponse = + serde_json::from_value(node_payload).expect("dig-app must read the node's richer payload"); + assert_eq!( + app.balance, 9_999, + "dig-app must read the confirmed balance verbatim" + ); } #[test] @@ -375,6 +420,17 @@ impl ControlHandler for MockNode { count: 0, }) } + async fn wallet_balance( + &self, + _params: WalletBalanceParams, + ) -> Result { + Ok(results::WalletBalanceResult { + balance: 1234, + pending: 0, + synced: true, + peak_height: Some(5_000_000), + }) + } async fn pairing_request( &self, _params: RequestParams, @@ -511,7 +567,7 @@ fn dispatcher_surfaces_a_handler_error_verbatim() { fn every_catalog_method_dispatches_without_panicking() { // The dispatcher must have an arm for EVERY catalog method — a missing arm would not compile, // but this also proves no method routes to a MethodNotFound (i.e. the name table + the match - // agree for all 29 methods). + // agree for all catalog methods). let node = MockNode; for &m in ControlMethod::ALL { let req = JsonRpcRequest::new(RequestId::Number(1), m.name(), minimal_params(m)); @@ -546,6 +602,7 @@ fn minimal_params(m: ControlMethod) -> Value { ControlMethod::Subscribe | ControlMethod::Unsubscribe => json!({"store_id": STORE}), ControlMethod::PairingRequest => json!({"client_name": "c"}), ControlMethod::PairingPoll => json!({"pairing_id": "x"}), + ControlMethod::WalletBalance => json!({"address": "xch1abc", "asset": "dig"}), _ => json!({}), } } diff --git a/src/method.rs b/src/method.rs index 61f2c84..a36d8a9 100644 --- a/src/method.rs +++ b/src/method.rs @@ -51,6 +51,8 @@ pub enum Category { Peers, /// The node's subscribed-store set. Subscriptions, + /// Read-only wallet chain reads (balance). + Wallet, } /// A dig-node CONTROL method. @@ -131,6 +133,10 @@ pub enum ControlMethod { /// `control.listSubscriptions` — the node's persisted subscription set. ListSubscriptions, + // ---- Wallet (READ-only, delegated to the engine) ---- + /// `control.wallet.balance` — read an address's confirmed spendable balance for an asset. + WalletBalance, + // ---- Pairing bootstrap (OPEN — no token) ---- /// `pairing.request` — request a control-token pairing (returns a code to compare). PairingRequest, @@ -169,6 +175,7 @@ impl ControlMethod { ControlMethod::Subscribe => "control.subscribe", ControlMethod::Unsubscribe => "control.unsubscribe", ControlMethod::ListSubscriptions => "control.listSubscriptions", + ControlMethod::WalletBalance => "control.wallet.balance", ControlMethod::PairingRequest => "pairing.request", ControlMethod::PairingPoll => "pairing.poll", } @@ -215,7 +222,8 @@ impl ControlMethod { | ControlMethod::PeersDisconnect | ControlMethod::Subscribe | ControlMethod::Unsubscribe - | ControlMethod::ListSubscriptions => Routing::Delegated, + | ControlMethod::ListSubscriptions + | ControlMethod::WalletBalance => Routing::Delegated, ControlMethod::PairingRequest | ControlMethod::PairingPoll => Routing::OpenBootstrap, _ => Routing::Owned, } @@ -251,6 +259,7 @@ impl ControlMethod { ControlMethod::Subscribe | ControlMethod::Unsubscribe | ControlMethod::ListSubscriptions => Category::Subscriptions, + ControlMethod::WalletBalance => Category::Wallet, } } @@ -284,6 +293,7 @@ impl ControlMethod { ControlMethod::Subscribe => "Subscribe the node to a store it actively watches and gap-fills.", ControlMethod::Unsubscribe => "Stop watching a store.", ControlMethod::ListSubscriptions => "The node's persisted subscription set + count.", + ControlMethod::WalletBalance => "READ-only: the confirmed spendable balance for an address + asset (plus pending, sync freshness, and the peak height it reflects).", ControlMethod::PairingRequest => "OPEN: request a control-token pairing; returns a pairing_id + pairing_code to compare.", ControlMethod::PairingPoll => "OPEN: poll a pairing by id; once the operator approves, returns the scoped token once.", } @@ -319,6 +329,7 @@ impl ControlMethod { ControlMethod::Subscribe, ControlMethod::Unsubscribe, ControlMethod::ListSubscriptions, + ControlMethod::WalletBalance, ControlMethod::PairingRequest, ControlMethod::PairingPoll, ]; @@ -396,6 +407,7 @@ mod tests { "control.subscribe", "control.unsubscribe", "control.listSubscriptions", + "control.wallet.balance", ] .into_iter() .collect(); diff --git a/src/params.rs b/src/params.rs index 199e973..ebe1161 100644 --- a/src/params.rs +++ b/src/params.rs @@ -202,6 +202,33 @@ pub struct UnsubscribeParams { } control_call!(UnsubscribeParams => ControlMethod::Unsubscribe, results::UnsubscribeResult); +/// The asset a wallet balance/coin read is denominated in. +/// +/// Serializes to a lowercase, language-neutral wire token (`"xch"` / `"dig"`) — byte-identical to the +/// frozen consumer type in dig-app (`dig-app-core::wallet::state::Asset`), so the contract and the +/// consumer share one wire form. Extended additively as the wallet grows to hold more CAT types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Asset { + /// Native Chia (XCH), denominated in mojos. + Xch, + /// The DIG CAT, denominated in its base units. + Dig, +} + +/// `control.wallet.balance` params: which address + asset to read the balance of. +/// +/// A READ over the loopback control plane — never a spend. Field names + the [`Asset`] wire form are +/// byte-identical to dig-app's frozen `BalanceRequest`, so the node reads exactly what dig-app emits. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WalletBalanceParams { + /// The `xch1…` address to read the balance of. + pub address: String, + /// The asset to read the balance for. + pub asset: Asset, +} +control_call!(WalletBalanceParams => ControlMethod::WalletBalance, results::WalletBalanceResult); + /// `pairing.request` params (OPEN — no token). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RequestParams { diff --git a/src/results.rs b/src/results.rs index d353fc1..898b076 100644 --- a/src/results.rs +++ b/src/results.rs @@ -283,6 +283,26 @@ pub struct ListSubscriptionsResult { pub count: u64, } +/// `control.wallet.balance` — an address's balance for one asset, as the node's chain read saw it. +/// +/// A READ-only result: this reports chain state, it never moves funds. It is a strict SUPERSET of +/// dig-app's frozen `BalanceResponse { balance }` — the node emits the richer shape, and because +/// dig-app's struct does not deny unknown fields it reads [`balance`](Self::balance) losslessly and +/// ignores the rest. That superset relationship is the "no dig-app code change" guarantee, pinned by +/// the conformance KAT. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WalletBalanceResult { + /// The CONFIRMED, spendable balance in the asset's base unit (mojos for XCH, base units for DIG). + /// The only field dig-app 3.x reads. + pub balance: u64, + /// Incoming funds seen but not yet confirmed (asset base units); not yet spendable. + pub pending: u64, + /// Whether the node's chain view is caught up. When `false`, the figures are STALE. + pub synced: bool, + /// The peak block height the reported figures reflect, or `null` when the node has no height yet. + pub peak_height: Option, +} + /// `pairing.request` — the pairing handshake bootstrap (OPEN, no token). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PairingRequestResult { diff --git a/src/traits.rs b/src/traits.rs index 04bbf5a..616ea80 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -192,6 +192,11 @@ pub trait ControlHandler: Sync { ) -> Result; /// `control.listSubscriptions` async fn list_subscriptions(&self) -> Result; + /// `control.wallet.balance` (READ-only) + async fn wallet_balance( + &self, + params: params::WalletBalanceParams, + ) -> Result; /// `pairing.request` (OPEN) async fn pairing_request( &self, @@ -279,6 +284,7 @@ pub trait ControlHandler: Sync { ControlMethod::Subscribe => encode(self.subscribe(decode(params)?).await?), ControlMethod::Unsubscribe => encode(self.unsubscribe(decode(params)?).await?), ControlMethod::ListSubscriptions => encode(self.list_subscriptions().await?), + ControlMethod::WalletBalance => encode(self.wallet_balance(decode(params)?).await?), ControlMethod::PairingRequest => encode(self.pairing_request(decode(params)?).await?), ControlMethod::PairingPoll => encode(self.pairing_poll(decode(params)?).await?), }