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
9 changes: 5 additions & 4 deletions 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 @@ -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
Expand Down
8 changes: 4 additions & 4 deletions SPEC.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/dig-node-service/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)` /
Expand Down
135 changes: 112 additions & 23 deletions crates/dig-node-service/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1377,6 +1370,42 @@ 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<BalanceAsset, Value> {
let Some(raw) = params.get("asset") else {
return Ok(BalanceAsset::Xch);
};
serde_json::from_value::<ControlAsset>(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).
Expand All @@ -1394,17 +1423,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))
}

Expand Down Expand Up @@ -2210,7 +2229,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":"<hex>"}` 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,
Expand Down Expand Up @@ -2713,7 +2735,7 @@ mod tests {
synced: true,
peak_height: Some(5_000_000),
},
BalanceAsset::Dig,
BalanceAsset::DIG,
);

assert_eq!(
Expand All @@ -2736,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
Expand Down
21 changes: 20 additions & 1 deletion crates/dig-node-service/src/control_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<hex>"}` 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 }),
Expand All @@ -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 }),
Expand Down
6 changes: 5 additions & 1 deletion crates/dig-wallet/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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."
Expand Down Expand Up @@ -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"
Comment thread
MichaelTaylor3d marked this conversation as resolved.
serde = { version = "1", features = ["derive"] }
serde_json = "1"
hex = "0.4"
Expand Down
Loading
Loading