From dbabab5008c3a5a4cfa561b29469467800394bbf Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Wed, 5 Aug 2026 22:24:45 -0700 Subject: [PATCH] feat(results): disclose which tier answered a wallet balance read Adds `source: "db" | "fallback"` to `WalletBalanceResult`, and tightens `synced`/`peak_height` to describe the tier that answered rather than the node. A wallet read is served either from the node's own chain replica or from a third-party coinset HTTP oracle, and the two are not interchangeable: the oracle path discloses the queried address off-node. Until now nothing on the wire said which one answered, so "the node answered from its own chain state" was not a falsifiable claim -- only a sync-progress flag was observable, and a flag can flip while the oracle keeps answering. Defining it here rather than in dig-node is the point: dig-app is the consumer, and a field defined ad hoc in the node would become a second literal for dig-app to mirror by hand -- the exact drift #2214 documents for the -3204x error codes. Additive in both directions. `source` is `Option`, so a payload from a node predating tier disclosure parses with the tier UNKNOWN rather than being rejected or silently assigned a tier. Consumers reading only `{balance}` are unaffected. Refs dig_ecosystem#2233 Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- SPEC.md | 18 ++++++++++++++---- src/kats.rs | 51 ++++++++++++++++++++++++++++++++++++++++++++++---- src/results.rs | 37 ++++++++++++++++++++++++++++++++++-- 5 files changed, 98 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 631c235..938bc7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ dependencies = [ [[package]] name = "dig-node-control-interface" -version = "0.4.0" +version = "0.5.0" dependencies = [ "async-trait", "futures", diff --git a/Cargo.toml b/Cargo.toml index fb60a0c..1c96623 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.4.0" +version = "0.5.0" edition = "2021" rust-version = "1.75.0" license = "Apache-2.0 OR MIT" diff --git a/SPEC.md b/SPEC.md index 74eecb8..92b454b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -93,7 +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}` | +| `control.wallet.balance` | yes | delegated | `{address:string, asset:"xch"\|"dig"}` | `{balance, pending, source, 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?}` | @@ -111,11 +111,21 @@ 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 +- **`WalletBalanceResult`**: `{balance:u64, pending:u64, source:"db"|"fallback"|null, 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). + `pending` is incoming-unconfirmed. + + `source` names the TIER that produced the figures, and every freshness field describes THAT tier: + `"db"` is the node's own chain replica (`synced:true`, `peak_height` = the replica's peak); + `"fallback"` is a third-party coinset HTTP oracle, which MUST report `synced:false` and + `peak_height:null` however caught-up the node's own replica is, because the replica neither + produced that figure nor bounds its freshness. A `"fallback"` answer also means the queried address + WAS DISCLOSED off-node. `source` is ABSENT/`null` only from a node predating tier disclosure — a + third state meaning "tier unknown", never a defaulted tier; consumers MUST NOT treat it as either. + `synced:false` means the figures are STALE or fallback-served; `peak_height` is the block height + the figures reflect (present as `null`, never omitted, when no height applies). 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. diff --git a/src/kats.rs b/src/kats.rs index c8d37b8..1646199 100644 --- a/src/kats.rs +++ b/src/kats.rs @@ -164,14 +164,55 @@ fn golden_response_result_vectors_are_byte_stable() { "status": "approved", "token": "deadbeef" })); assert_result_round_trips::(json!({ - "balance": 1234u64, "pending": 0u64, "synced": true, "peak_height": 5000000u32 + "balance": 1234u64, "pending": 0u64, + "source": "db", "synced": true, "peak_height": 5000000u32 })); - // `peak_height` is present as `null` (never omitted) when the node has no height yet. + // A fallback-tier answer: `synced` is false and `peak_height` is present as `null` (never + // omitted), because neither describes a figure the node's own replica did not produce. assert_result_round_trips::(json!({ - "balance": 0u64, "pending": 7u64, "synced": false, "peak_height": null + "balance": 0u64, "pending": 7u64, + "source": "fallback", "synced": false, "peak_height": null })); } +/// `source` is ADDITIVE in BOTH directions (dig_ecosystem#2233), which is the property that lets +/// this crate ship ahead of the nodes that emit the field. +/// +/// The fixture that matters is the one WITHOUT the key: a node released before tier disclosure +/// emits no `source` at all, and 0.5.0 must still parse that payload. A test that only round-trips +/// a payload carrying the field cannot see a missing `#[serde(default)]` — the field is required on +/// deserialize, every fixture supplies it, and the break surfaces only against a real older node. +#[test] +fn a_pre_disclosure_nodes_payload_still_parses_with_the_tier_unknown() { + let legacy = json!({ + "balance": 1234u64, "pending": 0u64, "synced": true, "peak_height": 5000000u32 + }); + let parsed: results::WalletBalanceResult = + serde_json::from_value(legacy).expect("a node predating `source` must still deserialize"); + + assert_eq!(parsed.balance, 1234); + assert_eq!( + parsed.source, None, + "an absent tier is UNKNOWN -- never silently reported as one of the two tiers" + ); +} + +/// The two tiers spell themselves on the wire as the lowercase tokens dig-node emits, pinned +/// literally so a Rust variant rename cannot silently change what a consumer must match on. +#[test] +fn the_tier_tokens_are_the_lowercase_wire_spellings() { + for (src, wire) in [ + (results::WalletReadSource::Db, "db"), + (results::WalletReadSource::Fallback, "fallback"), + ] { + assert_eq!(serde_json::to_value(src).unwrap(), json!(wire)); + assert_eq!( + serde_json::from_value::(json!(wire)).unwrap(), + src + ); + } +} + /// 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 @@ -179,7 +220,7 @@ fn golden_response_result_vectors_are_byte_stable() { #[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. + /// node's extra fields (`pending`/`source`/`synced`/`peak_height`) are ignored, not rejected. #[derive(serde::Deserialize)] struct DigAppBalanceResponse { balance: u64, @@ -189,6 +230,7 @@ fn node_balance_superset_is_readable_by_dig_apps_balance_struct() { let node_payload = serde_json::to_value(results::WalletBalanceResult { balance: 9_999, pending: 42, + source: Some(results::WalletReadSource::Db), synced: true, peak_height: Some(6_123_456), }) @@ -427,6 +469,7 @@ impl ControlHandler for MockNode { Ok(results::WalletBalanceResult { balance: 1234, pending: 0, + source: Some(results::WalletReadSource::Db), synced: true, peak_height: Some(5_000_000), }) diff --git a/src/results.rs b/src/results.rs index 33eba48..c6a110e 100644 --- a/src/results.rs +++ b/src/results.rs @@ -478,12 +478,45 @@ pub struct WalletBalanceResult { 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. + /// Which tier produced these figures, or `None` from a node too old to disclose it. + /// + /// See [`WalletReadSource`]. Absent (`null` / omitted) is a THIRD state, not a default tier: + /// it means the answering node predates tier disclosure, so the caller knows the tier is + /// unknown rather than being told a tier that was never reported. + /// + /// The [`Option`] carries the backwards compatibility on its own — serde treats a missing + /// `Option` field as `None` — so no `#[serde(default)]` is needed and none is written; a + /// REQUIRED field here would reject an older node's payload outright. + pub source: Option, + /// Whether THESE figures reflect a caught-up local view. When `false`, they are STALE or came + /// from the fallback tier. + /// + /// This describes the ANSWER, not the node: a [`WalletReadSource::Fallback`] answer is always + /// `false`, however caught-up the node's own replica happens to be. pub synced: bool, - /// The peak block height the reported figures reflect, or `null` when the node has no height yet. + /// The peak block height the reported figures reflect, or `null` when no height applies — + /// including every [`WalletReadSource::Fallback`] answer, whose figures came from the oracle's + /// chain view rather than the node's. pub peak_height: Option, } +/// Which tier answered a wallet read (dig_ecosystem#2233). +/// +/// A node serves a wallet read either from its own chain replica or from a third-party HTTP +/// oracle, and the two are not interchangeable to a caller: the oracle path is a network round +/// trip that **discloses the queried address off-node**, which a user on a metered or private +/// connection has a legitimate interest in knowing about. Reporting the tier is also what makes +/// "the node answered from its own chain state" a falsifiable claim — a sync-progress flag is not, +/// since a flag can flip while the oracle keeps answering. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WalletReadSource { + /// The node's own local chain replica. No third party was consulted. + Db, + /// A third-party coinset HTTP oracle. The queried address was disclosed off-node. + Fallback, +} + /// `pairing.request` — the pairing handshake bootstrap (OPEN, no token). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PairingRequestResult {