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.2.0"
version = "0.3.0"
edition = "2021"
rust-version = "1.75.0"
license = "Apache-2.0 OR MIT"
Expand Down
9 changes: 9 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?}` |

Expand All @@ -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
Expand Down
59 changes: 58 additions & 1 deletion src/kats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -156,6 +163,44 @@ fn golden_response_result_vectors_are_byte_stable() {
assert_result_round_trips::<results::PairingPollResult>(json!({
"status": "approved", "token": "deadbeef"
}));
assert_result_round_trips::<results::WalletBalanceResult>(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::<results::WalletBalanceResult>(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]
Expand Down Expand Up @@ -375,6 +420,17 @@ impl ControlHandler for MockNode {
count: 0,
})
}
async fn wallet_balance(
&self,
_params: WalletBalanceParams,
) -> Result<results::WalletBalanceResult, ControlError> {
Ok(results::WalletBalanceResult {
balance: 1234,
pending: 0,
synced: true,
peak_height: Some(5_000_000),
})
}
async fn pairing_request(
&self,
_params: RequestParams,
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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!({}),
}
}
14 changes: 13 additions & 1 deletion src/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
}
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -251,6 +259,7 @@ impl ControlMethod {
ControlMethod::Subscribe
| ControlMethod::Unsubscribe
| ControlMethod::ListSubscriptions => Category::Subscriptions,
ControlMethod::WalletBalance => Category::Wallet,
}
}

Expand Down Expand Up @@ -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.",
}
Expand Down Expand Up @@ -319,6 +329,7 @@ impl ControlMethod {
ControlMethod::Subscribe,
ControlMethod::Unsubscribe,
ControlMethod::ListSubscriptions,
ControlMethod::WalletBalance,
ControlMethod::PairingRequest,
ControlMethod::PairingPoll,
];
Expand Down Expand Up @@ -396,6 +407,7 @@ mod tests {
"control.subscribe",
"control.unsubscribe",
"control.listSubscriptions",
"control.wallet.balance",
]
.into_iter()
.collect();
Expand Down
27 changes: 27 additions & 0 deletions src/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions src/results.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
}

/// `pairing.request` — the pairing handshake bootstrap (OPEN, no token).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairingRequestResult {
Expand Down
6 changes: 6 additions & 0 deletions src/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ pub trait ControlHandler: Sync {
) -> Result<results::UnsubscribeResult, ControlError>;
/// `control.listSubscriptions`
async fn list_subscriptions(&self) -> Result<results::ListSubscriptionsResult, ControlError>;
/// `control.wallet.balance` (READ-only)
async fn wallet_balance(
&self,
params: params::WalletBalanceParams,
) -> Result<results::WalletBalanceResult, ControlError>;
/// `pairing.request` (OPEN)
async fn pairing_request(
&self,
Expand Down Expand Up @@ -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?),
}
Expand Down
Loading