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
2 changes: 1 addition & 1 deletion 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 @@ -13,7 +13,7 @@
# is designed, matching the sibling dig-<x>-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"
Expand Down
18 changes: 14 additions & 4 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?}` |

Expand All @@ -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.
Expand Down
51 changes: 47 additions & 4 deletions src/kats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,22 +164,63 @@ fn golden_response_result_vectors_are_byte_stable() {
"status": "approved", "token": "deadbeef"
}));
assert_result_round_trips::<results::WalletBalanceResult>(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::<results::WalletBalanceResult>(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::<results::WalletReadSource>(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
/// 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.
/// node's extra fields (`pending`/`source`/`synced`/`peak_height`) are ignored, not rejected.
#[derive(serde::Deserialize)]
struct DigAppBalanceResponse {
balance: u64,
Expand All @@ -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),
})
Expand Down Expand Up @@ -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),
})
Expand Down
37 changes: 35 additions & 2 deletions src/results.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<WalletReadSource>,
/// 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<u32>,
}

Comment thread
MichaelTaylor3d marked this conversation as resolved.
/// 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")]
Comment thread
MichaelTaylor3d marked this conversation as resolved.
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 {
Expand Down
Loading