use futures::executor::block_on;
use serde_json::{json, Value};
use crate::envelope::{JsonRpcRequest, JsonRpcResponse, RequestId};
use crate::error::{ControlError, ControlErrorCode};
use crate::method::ControlMethod;
use crate::params::*;
use crate::results;
use crate::traits::{build_request, parse_response, ControlHandler};
const REJECTED_BUNDLE: &str = "beef";
const UNSUPPORTED_UPSTREAM: &str = "__not_supported__";
const SPENT_COIN: &str = "abababababababababababababababababababababababababababababababab";
const ABSENT_COIN: &str = "0101010101010101010101010101010101010101010101010101010101010101";
const CHILD_COINS: [&str; 4] = [
"1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a",
"2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b",
"3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c",
"4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d",
];
const ADDRESS_COINS: [&str; 4] = [
"5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e",
"6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f",
"7070707070707070707070707070707070707070707070707070707070707070",
"8181818181818181818181818181818181818181818181818181818181818181",
];
const REVEAL_HEX: &str = "ff01ff8080";
const SOLUTION_HEX: &str = "ff8203e880";
const STORE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const ROOT: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
fn assert_request<C: crate::traits::ControlCall>(call: &C, expected: Value) {
let req = build_request(RequestId::Number(1), call);
assert_eq!(
serde_json::to_value(&req).unwrap(),
expected,
"request wire shape drifted for {}",
C::METHOD.name()
);
}
fn assert_result_round_trips<T>(wire: Value)
where
T: serde::Serialize + serde::de::DeserializeOwned,
{
let parsed: T = serde_json::from_value(wire.clone()).expect("golden result must decode");
assert_eq!(
serde_json::to_value(&parsed).unwrap(),
wire,
"result wire shape is not byte-stable"
);
}
#[test]
fn golden_request_vectors() {
assert_request(
&StatusParams {},
json!({"jsonrpc":"2.0","id":1,"method":"control.status","params":{}}),
);
assert_request(
&SetUpstreamParams {
upstream: "https://rpc.dig.net".into(),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.config.setUpstream","params":{"upstream":"https://rpc.dig.net"}}),
);
assert_request(
&SetLevelParams {
filter: "info,dig_node_core=debug".into(),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.log.setLevel","params":{"filter":"info,dig_node_core=debug"}}),
);
assert_request(
&SetCapParams {
cap_bytes: 67108864,
},
json!({"jsonrpc":"2.0","id":1,"method":"control.cache.setCap","params":{"cap_bytes":67108864}}),
);
assert_request(
&PinParams {
store: format!("{STORE}:{ROOT}"),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.hostedStores.pin","params":{"store":format!("{STORE}:{ROOT}")}}),
);
assert_request(
&CapsuleFetchParams {
store: STORE.into(),
root: ROOT.into(),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.capsule.fetch","params":{"store":STORE,"root":ROOT}}),
);
assert_request(
&SyncTriggerParams {
store: format!("{STORE}:{ROOT}"),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.sync.trigger","params":{"store":format!("{STORE}:{ROOT}")}}),
);
assert_request(
&PauseParams {
until: Some(1_800_000_000),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.updater.pause","params":{"until":1800000000}}),
);
assert_request(
&ApproveParams {
pairing_id: "pid-1".into(),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.pairing.approve","params":{"pairing_id":"pid-1"}}),
);
assert_request(
&PeersConnectParams {
peer: "1.2.3.4:9257".into(),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.peers.connect","params":{"peer":"1.2.3.4:9257"}}),
);
assert_request(
&SubscribeParams {
store_id: STORE.into(),
kind: SubscriptionKind::Capsule,
},
json!({"jsonrpc":"2.0","id":1,"method":"control.subscribe","params":{"store_id":STORE,"kind":"capsule"}}),
);
assert_request(
&RequestParams {
client_name: "DIG extension".into(),
},
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"}}),
);
assert_request(
&WalletCoinsParams::first_page("xch1exampleaddr", Asset::Xch),
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.coins","params":{"address":"xch1exampleaddr","asset":"xch"}}),
);
assert_request(
&WalletBalanceParams {
address: "xch1exampleaddr".into(),
asset: Asset::Cat(AssetId::from_hex(&"3c".repeat(32)).unwrap()),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.balance","params":{"address":"xch1exampleaddr","asset":{"cat":"3c".repeat(32)}}}),
);
assert_request(
&WalletCoinsParams::first_page(
"xch1exampleaddr",
Asset::Cat(AssetId::from_hex(&"3c".repeat(32)).unwrap()),
),
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.coins","params":{"address":"xch1exampleaddr","asset":{"cat":"3c".repeat(32)}}}),
);
assert_request(
&WalletCoinByIdParams {
coin_id: "ab".repeat(32),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.coinById","params":{"coin_id":"ab".repeat(32)}}),
);
assert_request(
&WalletCoinSpendParams {
coin_id: "ab".repeat(32),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.coinSpend","params":{"coin_id":"ab".repeat(32)}}),
);
assert_request(
&WalletCoinsByParentParams::first_page("ab".repeat(32)),
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.coinsByParent","params":{"parent_coin_id":"ab".repeat(32)}}),
);
assert_request(
&WalletCoinsByParentParams {
parent_coin_id: "ab".repeat(32),
after_coin_id: Some(CHILD_COINS[1].into()),
limit: Some(2),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.coinsByParent","params":{"parent_coin_id":"ab".repeat(32),"after_coin_id":CHILD_COINS[1],"limit":2}}),
);
assert_request(
&WalletPeakParams {},
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.peak","params":{}}),
);
assert_request(
&PeerCountsParams {},
json!({"jsonrpc":"2.0","id":1,"method":"control.peerCounts","params":{}}),
);
assert_request(
&WalletSyncStatusParams {},
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.syncStatus","params":{}}),
);
assert_request(
&WalletBroadcastParams {
signed_bundle_hex: "deadbeef".into(),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.broadcast","params":{"signed_bundle_hex":"deadbeef"}}),
);
}
#[test]
fn golden_response_result_vectors_are_byte_stable() {
assert_result_round_trips::<results::StatusResult>(json!({
"running": true, "service": "dig-node", "version": "0.30.0", "commit": "deadbee",
"protocol": "21", "uptime_secs": 42, "addr": "127.0.0.1:9256",
"upstream": "https://rpc.dig.net",
"cache": {"cap_bytes": 67108864, "used_bytes": 1024, "dir": "/var/cache/dig", "shared": true},
"hosted_store_count": 3, "cached_capsule_count": 5, "pinned_store_count": 2,
"sync": {"available": true}
}));
assert_result_round_trips::<results::ConfigResult>(json!({
"addr": "127.0.0.1:9256", "port": "9256", "upstream": "https://rpc.dig.net",
"upstream_override": null, "cache_dir": "/var/cache/dig", "cache_shared": true,
"config_path": "/etc/dig/config.json", "sync_available": false
}));
assert_result_round_trips::<results::CacheView>(json!({
"cap_bytes": 67108864, "used_bytes": 0, "dir": "/c", "shared": false
}));
assert_result_round_trips::<results::HostedStoresListResult>(json!({
"stores": [{
"store_id": STORE, "pinned": true, "capsule_count": 1, "total_bytes": 10,
"capsules": [{"capsule": format!("{STORE}:{ROOT}"), "root": ROOT,
"size_bytes": 10, "last_used_unix_ms": 1700000000000u64}]
}]
}));
assert_result_round_trips::<results::SyncStatusResult>(json!({
"available": true, "method": "section-21-whole-store-sync",
"pinned_total": 2, "pinned_synced": 1, "whole_store_trigger_supported": false
}));
assert_result_round_trips::<results::CapsuleFetchResult>(json!({
"store": STORE, "root": ROOT, "status": "started"
}));
assert_result_round_trips::<results::SyncTriggerResult>(json!({
"store_id": STORE, "root": ROOT, "status": "synced",
"size_bytes": 2048, "served_root": ROOT
}));
assert_result_round_trips::<results::SubscribeResult>(json!({
"subscribed": true, "added": true, "store_id": STORE, "kind": "profile"
}));
assert_result_round_trips::<results::ListSubscriptionsResult>(json!({
"subscriptions": [STORE], "count": 1
}));
assert_result_round_trips::<results::PairingApproveResult>(json!({
"approved": true, "client_name": "DIG extension", "token_id": "abcd1234"
}));
assert_result_round_trips::<results::PairingPollResult>(json!({
"status": "approved", "token": "deadbeef"
}));
assert_result_round_trips::<results::WalletBalanceResult>(json!({
"balance": 1234u64, "pending": 0u64,
"source": "db", "synced": true, "peak_height": 5000000u32
}));
assert_result_round_trips::<results::WalletCoinsResult>(json!({
"coins": [{
"coin_id": "aa".repeat(32), "asset": "xch", "amount": 1_750_000_000_000u64,
"parent_coin_info": "bb".repeat(32), "puzzle_hash": "cc".repeat(32),
"created_height": 5_000_000u32, "spent_height": null
}],
"complete": true, "cursor": "aa".repeat(32),
"source": "db", "synced": true, "peak_height": 5_000_000u32
}));
assert_result_round_trips::<results::WalletCoinsResult>(json!({
"coins": [], "complete": true, "cursor": null,
"source": "fallback", "synced": false, "peak_height": null
}));
assert_result_round_trips::<results::WalletCoinByIdResult>(json!({
"coin": {
"coin_id": "ab".repeat(32), "asset": null, "amount": 1_000_000_000_000u64,
"parent_coin_info": "bb".repeat(32), "puzzle_hash": "cc".repeat(32),
"created_height": 5_000_000u32, "spent_height": 5_000_042u32
},
"source": "fallback", "synced": false, "peak_height": null
}));
assert_result_round_trips::<results::WalletCoinByIdResult>(json!({
"coin": null, "source": "fallback", "synced": false, "peak_height": null
}));
assert_result_round_trips::<results::WalletCoinByIdResult>(json!({
"coin": {
"coin_id": "ab".repeat(32), "asset": null, "amount": 1_000_000_000_000u64,
"parent_coin_info": "bb".repeat(32), "puzzle_hash": "cc".repeat(32),
"created_height": 5_000_000u32, "spent_height": null
},
"source": "db", "synced": true, "peak_height": 5_000_100u32
}));
assert_result_round_trips::<results::WalletCoinSpendResult>(json!({
"spend": {
"coin": {
"coin_id": "ab".repeat(32), "asset": null, "amount": 1_000_000_000_000u64,
"parent_coin_info": "bb".repeat(32), "puzzle_hash": "cc".repeat(32),
"created_height": 5_000_000u32, "spent_height": 5_000_042u32
},
"puzzle_reveal": REVEAL_HEX, "solution": SOLUTION_HEX
},
"source": "db", "synced": true, "peak_height": 5_000_100u32
}));
assert_result_round_trips::<results::WalletCoinSpendResult>(json!({
"spend": null, "source": "fallback", "synced": false, "peak_height": null
}));
assert_result_round_trips::<results::WalletCoinsByParentResult>(json!({
"coins": [{
"coin_id": CHILD_COINS[0], "asset": null, "amount": 999_999_999_999u64,
"parent_coin_info": "ab".repeat(32), "puzzle_hash": "33".repeat(32),
"created_height": 5_000_042u32, "spent_height": null
}],
"complete": true, "cursor": CHILD_COINS[0],
"source": "db", "synced": true, "peak_height": 5_000_100u32
}));
assert_result_round_trips::<results::WalletCoinsByParentResult>(json!({
"coins": [{
"coin_id": CHILD_COINS[0], "asset": null, "amount": 999_999_999_999u64,
"parent_coin_info": "ab".repeat(32), "puzzle_hash": "33".repeat(32),
"created_height": 5_000_042u32, "spent_height": null
}],
"complete": false, "cursor": CHILD_COINS[0],
"source": "db", "synced": true, "peak_height": 5_000_100u32
}));
assert_result_round_trips::<results::WalletCoinsByParentResult>(json!({
"coins": [], "complete": true, "cursor": null,
"source": "fallback", "synced": false, "peak_height": null
}));
assert_result_round_trips::<results::WalletPeakResult>(json!({
"peak_height": 5_000_000u32, "synced": true
}));
assert_result_round_trips::<results::WalletPeakResult>(json!({
"peak_height": null, "synced": false
}));
assert_result_round_trips::<results::WalletSyncStatusResult>(json!({
"phase": "not_started", "peak_height": null, "chia_peer_count": 0u32,
"watched_addresses": 0u32, "subscription_peer_count": null, "chia_peer_peak_height": null
}));
assert_result_round_trips::<results::WalletSyncStatusResult>(json!({
"phase": "syncing", "peak_height": 4_000_000u32, "chia_peer_count": 3u32,
"watched_addresses": 12u32, "subscription_peer_count": 1u32,
"chia_peer_peak_height": 4_000_200u32
}));
assert_result_round_trips::<results::WalletSyncStatusResult>(json!({
"phase": "synced", "peak_height": 5_000_000u32, "chia_peer_count": 5u32,
"watched_addresses": 12u32, "subscription_peer_count": 1u32,
"chia_peer_peak_height": 5_000_000u32
}));
assert_result_round_trips::<results::WalletSyncStatusResult>(json!({
"phase": "not_started", "peak_height": 4_900_000u32, "chia_peer_count": 0u32,
"watched_addresses": 12u32, "subscription_peer_count": null, "chia_peer_peak_height": null
}));
assert_result_round_trips::<results::WalletSyncStatusResult>(json!({
"phase": "syncing", "peak_height": null, "chia_peer_count": null,
"watched_addresses": null, "subscription_peer_count": null, "chia_peer_peak_height": null
}));
assert_result_round_trips::<results::WalletSyncStatusResult>(json!({
"phase": "no_wallet_enrolled", "peak_height": null, "chia_peer_count": 0u32,
"watched_addresses": 0u32, "subscription_peer_count": null, "chia_peer_peak_height": null
}));
assert_result_round_trips::<results::WalletSyncStatusResult>(json!({
"phase": "wallet_not_unlocked", "peak_height": 4_900_000u32, "chia_peer_count": 2u32,
"watched_addresses": 0u32, "subscription_peer_count": null, "chia_peer_peak_height": null
}));
assert_result_round_trips::<results::WalletSyncStatusResult>(json!({
"phase": "a_phase_from_a_newer_node", "peak_height": 5_000_000u32, "chia_peer_count": 3u32,
"watched_addresses": 12u32, "subscription_peer_count": 1u32,
"chia_peer_peak_height": 5_000_100u32
}));
assert_result_round_trips::<results::PeerCountsResult>(json!({
"dig_peer_count": 6u32, "chia_peer_count": 3u32, "known_dig_peer_count": 41u32
}));
assert_result_round_trips::<results::PeerCountsResult>(json!({
"dig_peer_count": 0u32, "chia_peer_count": 0u32, "known_dig_peer_count": 0u32
}));
assert_result_round_trips::<results::PeerCountsResult>(json!({
"dig_peer_count": null, "chia_peer_count": null, "known_dig_peer_count": null
}));
assert_result_round_trips::<results::PeerCountsResult>(json!({
"dig_peer_count": 6u32, "chia_peer_count": null, "known_dig_peer_count": 41u32
}));
assert_result_round_trips::<results::PeerCountsResult>(json!({
"dig_peer_count": 0u32, "chia_peer_count": 3u32, "known_dig_peer_count": 41u32
}));
assert_result_round_trips::<results::WalletBroadcastResult>(json!({
"accepted": true, "transaction_id": "dd".repeat(32), "rejection": null
}));
assert_result_round_trips::<results::WalletBroadcastResult>(json!({
"accepted": false, "transaction_id": null, "rejection": "DOUBLE_SPEND"
}));
assert_result_round_trips::<results::WalletBalanceResult>(json!({
"balance": 0u64, "pending": 7u64,
"source": "fallback", "synced": false, "peak_height": null
}));
}
#[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"
);
}
#[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
);
}
}
#[test]
fn the_wallet_sync_phase_tokens_are_the_snake_case_wire_spellings() {
let pinned: &[(results::WalletSyncPhase, &str)] = &[
(results::WalletSyncPhase::NotStarted, "not_started"),
(results::WalletSyncPhase::Syncing, "syncing"),
(results::WalletSyncPhase::Synced, "synced"),
(
results::WalletSyncPhase::NoWalletEnrolled,
"no_wallet_enrolled",
),
(
results::WalletSyncPhase::WalletNotUnlocked,
"wallet_not_unlocked",
),
];
for (phase, wire) in pinned {
assert_eq!(serde_json::to_value(phase).unwrap(), json!(wire));
assert_eq!(
&serde_json::from_value::<results::WalletSyncPhase>(json!(wire)).unwrap(),
phase
);
assert_eq!(phase.as_wire(), *wire, "as_wire must be the same spelling");
}
for phase in results::WalletSyncPhase::ALL {
assert!(
pinned.iter().any(|(pinned_phase, _)| pinned_phase == phase),
"{phase:?} is in ALL but has no pinned wire spelling"
);
assert!(
phase.is_recognized(),
"ALL enumerates the KNOWN phases; Unrecognized is the absence of one"
);
assert_eq!(
expected_wire(phase),
Some(phase.as_wire()),
"{phase:?} disagrees with the compiler-checked spelling table"
);
}
for (i, phase) in results::WalletSyncPhase::ALL.iter().enumerate() {
assert!(
!results::WalletSyncPhase::ALL[..i].contains(phase),
"{phase:?} appears twice in ALL"
);
}
assert_eq!(
results::WalletSyncPhase::ALL.len(),
pinned.len(),
"every pinned phase must also appear in ALL — the node side derives its conformance \
assertion from ALL, so a variant missing here disables that check silently"
);
}
fn expected_wire(phase: &results::WalletSyncPhase) -> Option<&'static str> {
match phase {
results::WalletSyncPhase::NotStarted => Some("not_started"),
results::WalletSyncPhase::Syncing => Some("syncing"),
results::WalletSyncPhase::Synced => Some("synced"),
results::WalletSyncPhase::NoWalletEnrolled => Some("no_wallet_enrolled"),
results::WalletSyncPhase::WalletNotUnlocked => Some("wallet_not_unlocked"),
results::WalletSyncPhase::Unrecognized(_) => None,
}
}
#[test]
fn every_phase_decodes_back_to_itself_from_its_own_wire_spelling() {
let unknown_spellings = [
"no_addresses_to_watch",
"a_newer_token",
"",
"SYNCED",
" synced",
];
let reachable = results::WalletSyncPhase::ALL
.iter()
.map(|phase| phase.as_wire().to_owned())
.chain(unknown_spellings.iter().map(|s| (*s).to_owned()));
for spelling in reachable {
let phase = results::WalletSyncPhase::from(spelling.as_str());
assert_eq!(
results::WalletSyncPhase::from(phase.as_wire()),
phase,
"{spelling:?} produced a phase whose own spelling decodes to something else"
);
assert_eq!(
phase.is_recognized(),
results::WalletSyncPhase::ALL.contains(&phase),
"{spelling:?}: is_recognized() must agree with membership of the known set"
);
if !phase.is_recognized() {
assert!(
!results::WalletSyncPhase::ALL
.iter()
.any(|known| known.as_wire() == phase.as_wire()),
"{spelling:?} is unrecognised locally but spells itself as a known phase"
);
}
}
}
#[test]
fn never_started_is_distinguishable_from_synced_at_height_zero() {
let never_started = serde_json::to_value(results::WalletSyncStatusResult {
phase: results::WalletSyncPhase::NotStarted,
peak_height: None,
chia_peer_count: Some(0),
watched_addresses: Some(0),
subscription_peer_count: None,
chia_peer_peak_height: None,
})
.unwrap();
let synced_at_genesis = serde_json::to_value(results::WalletSyncStatusResult {
phase: results::WalletSyncPhase::Synced,
peak_height: Some(0),
chia_peer_count: Some(1),
watched_addresses: Some(4),
subscription_peer_count: Some(1),
chia_peer_peak_height: Some(0),
})
.unwrap();
assert_ne!(never_started, synced_at_genesis);
assert_eq!(never_started["peak_height"], json!(null));
assert_eq!(
synced_at_genesis["peak_height"],
json!(0),
"height 0 is a real height and must survive the round trip as one"
);
}
#[test]
fn the_spec_and_readme_name_every_catalogued_method() {
for (doc, text) in [
("SPEC.md", include_str!("../SPEC.md")),
("README.md", include_str!("../README.md")),
] {
for &m in ControlMethod::ALL {
assert!(
text.contains(m.name()),
"{doc} never mentions `{}` -- the document claims to be exhaustive",
m.name()
);
}
for phase in results::WalletSyncPhase::ALL {
let quoted = format!("{:?}", phase.as_wire());
assert!(
text.contains("ed),
"{doc} never mentions the {quoted} phase token in its quoted wire spelling"
);
}
}
let summary = ControlMethod::WalletSyncStatus.summary();
for phase in results::WalletSyncPhase::ALL {
assert!(
summary.contains(phase.as_wire()),
"control.wallet.syncStatus's summary omits the `{}` token",
phase.as_wire()
);
}
}
#[test]
fn each_peer_count_key_names_its_network() {
let wire = serde_json::to_value(results::PeerCountsResult {
dig_peer_count: Some(6),
chia_peer_count: Some(3),
known_dig_peer_count: Some(41),
})
.unwrap();
assert_eq!(wire["dig_peer_count"], json!(6));
assert_eq!(wire["chia_peer_count"], json!(3));
assert_eq!(wire["known_dig_peer_count"], json!(41));
let keys: Vec<&str> = wire
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(
keys,
vec!["chia_peer_count", "dig_peer_count", "known_dig_peer_count"]
);
assert_eq!(
serde_json::to_string(&results::PeerCountsResult {
dig_peer_count: Some(6),
chia_peer_count: Some(3),
known_dig_peer_count: Some(41),
})
.unwrap(),
r#"{"dig_peer_count":6,"chia_peer_count":3,"known_dig_peer_count":41}"#,
"the emitted bytes name each network and give the two counts independently"
);
}
#[test]
fn both_results_spell_the_chia_count_with_the_same_key() {
const KEY: &str = "chia_peer_count";
let counts = serde_json::to_value(results::PeerCountsResult {
dig_peer_count: None,
chia_peer_count: Some(3),
known_dig_peer_count: None,
})
.unwrap();
let sync = serde_json::to_value(results::WalletSyncStatusResult {
phase: results::WalletSyncPhase::Syncing,
peak_height: Some(4_000_000),
chia_peer_count: Some(3),
watched_addresses: Some(4),
subscription_peer_count: Some(1),
chia_peer_peak_height: Some(4_000_100),
})
.unwrap();
assert_eq!(counts[KEY], json!(3));
assert_eq!(sync[KEY], json!(3));
assert_eq!(
counts[KEY], sync[KEY],
"a single node's two answers describe the same observation and must agree"
);
}
#[test]
fn an_unobservable_count_is_null_not_zero_on_either_network() {
let dig_unknown = serde_json::to_value(results::PeerCountsResult {
dig_peer_count: None,
chia_peer_count: Some(0),
known_dig_peer_count: Some(41),
})
.unwrap();
assert_eq!(dig_unknown["dig_peer_count"], json!(null));
assert_eq!(
dig_unknown["chia_peer_count"],
json!(0),
"an observed zero must survive as a zero beside an unknown"
);
let chia_unknown = serde_json::to_value(results::PeerCountsResult {
dig_peer_count: Some(0),
chia_peer_count: None,
known_dig_peer_count: Some(41),
})
.unwrap();
assert_eq!(chia_unknown["dig_peer_count"], json!(0));
assert_eq!(chia_unknown["chia_peer_count"], json!(null));
assert_ne!(dig_unknown, chia_unknown);
}
#[test]
fn node_balance_superset_is_readable_by_dig_apps_balance_struct() {
#[derive(serde::Deserialize)]
struct DigAppBalanceResponse {
balance: u64,
}
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),
})
.unwrap();
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]
fn error_envelope_golden_vector() {
let resp = JsonRpcResponse::error(
RequestId::Number(1),
ControlError::of(ControlErrorCode::Unauthorized, "control.* requires a token"),
);
assert_eq!(
serde_json::to_value(&resp).unwrap(),
json!({
"jsonrpc": "2.0", "id": 1,
"error": {
"code": -32030,
"message": "control.* requires a token",
"data": {"code": "UNAUTHORIZED", "origin": "shell"}
}
})
);
}
const ENROL_KEY_A: &str = "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1";
const ENROL_KEY_B: &str = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2";
thread_local! {
static ENROLLED: std::cell::RefCell<std::collections::BTreeSet<String>> =
const { std::cell::RefCell::new(std::collections::BTreeSet::new()) };
}
const MOCK_NOW_UNIX: u64 = 1_800_000_000;
const MOCK_MAX_TTL_SECS: u64 = 600;
const MOCK_DEFAULT_TTL_SECS: u64 = 300;
const RESERVE_COIN_A: &str = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1";
const RESERVE_COIN_B: &str = "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2";
thread_local! {
static MARGIN_BP: std::cell::RefCell<u64> = const { std::cell::RefCell::new(crate::params::DEFAULT_SAFETY_MARGIN_BP) };
}
thread_local! {
static RESERVATIONS: std::cell::RefCell<
std::collections::BTreeMap<String, (Vec<String>, u64)>,
> = const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
}
struct MockNode;
pub const MOCK_TRUSTED_CHIA_PEER: &str = "203.0.113.7";
pub const MOCK_BANNED_CHIA_PEER: &str = "198.51.100.9";
pub const MOCK_UNBANNED_WITHOUT_TRUST_NOTICE: &str =
"This peer is no longer banned, but it was NOT granted trust: chain answers from it still require corroboration from other peers.";
pub const MOCK_CORROBORATION_BYPASS_NOTICE: &str =
"This node will now believe 203.0.113.7 WITHOUT corroboration: chain answers from it are \
accepted on their own, with no agreement from other peers. Add only a node you run yourself.";
#[async_trait::async_trait]
impl ControlHandler for MockNode {
async fn status(&self) -> Result<results::StatusResult, ControlError> {
Ok(results::StatusResult {
running: true,
service: "dig-node".into(),
version: "0.30.0".into(),
commit: "deadbee".into(),
protocol: "21".into(),
uptime_secs: 1,
addr: "127.0.0.1:9256".into(),
upstream: "https://rpc.dig.net".into(),
cache: results::CacheView {
cap_bytes: 67108864,
used_bytes: 0,
dir: "/c".into(),
shared: false,
},
hosted_store_count: 0,
cached_capsule_count: 0,
pinned_store_count: 0,
sync: results::SyncAvailability { available: false },
})
}
async fn config_get(&self) -> Result<results::ConfigResult, ControlError> {
Ok(results::ConfigResult {
addr: "127.0.0.1".into(),
port: "9256".into(),
upstream: "https://rpc.dig.net".into(),
upstream_override: None,
cache_dir: "/tmp/cache".into(),
cache_shared: false,
config_path: "/tmp/config.json".into(),
sync_available: true,
})
}
async fn config_set_upstream(
&self,
params: SetUpstreamParams,
) -> Result<results::SetUpstreamResult, ControlError> {
if params.upstream == UNSUPPORTED_UPSTREAM {
return Err(unimpl("config_set_upstream"));
}
Ok(results::SetUpstreamResult {
upstream: params.upstream,
requires_restart: true,
})
}
async fn log_set_level(
&self,
params: SetLevelParams,
) -> Result<results::SetLevelResult, ControlError> {
Ok(results::SetLevelResult {
filter: params.filter,
})
}
async fn cache_get(&self) -> Result<results::CacheView, ControlError> {
Ok(results::CacheView {
cap_bytes: 64 * 1024 * 1024,
used_bytes: 1024,
dir: "/tmp/cache".into(),
shared: false,
})
}
async fn cache_set_cap(
&self,
params: SetCapParams,
) -> Result<results::SetCapResult, ControlError> {
Ok(results::SetCapResult {
cap_bytes: params.cap_bytes.max(64 * 1024 * 1024),
})
}
async fn cache_clear(&self) -> Result<results::CacheClearResult, ControlError> {
Ok(results::CacheClearResult { cleared: true })
}
async fn hosted_stores_list(&self) -> Result<results::HostedStoresListResult, ControlError> {
Ok(results::HostedStoresListResult { stores: vec![] })
}
async fn hosted_stores_pin(
&self,
params: PinParams,
) -> Result<results::PinResult, ControlError> {
Ok(results::PinResult {
store_id: params.store,
root: None,
pinned: true,
fetch: json!({"status": "skipped"}),
})
}
async fn hosted_stores_unpin(
&self,
params: UnpinParams,
) -> Result<results::UnpinResult, ControlError> {
Ok(results::UnpinResult {
store_id: params.store,
unpinned: true,
evicted_capsules: 0,
})
}
async fn hosted_stores_status(
&self,
params: HostedStoreStatusParams,
) -> Result<results::HostedStoreStatusResult, ControlError> {
Ok(results::HostedStoreStatusResult {
store_id: params.store,
pinned: false,
capsule_count: 0,
total_bytes: 0,
capsules: vec![],
})
}
async fn capsule_fetch(
&self,
params: CapsuleFetchParams,
) -> Result<results::CapsuleFetchResult, ControlError> {
Ok(results::CapsuleFetchResult {
store: params.store,
root: params.root,
status: "started".into(),
})
}
async fn sync_status(&self) -> Result<results::SyncStatusResult, ControlError> {
Ok(results::SyncStatusResult {
available: true,
method: "dig-sync".into(),
pinned_total: 0,
pinned_synced: 0,
whole_store_trigger_supported: true,
})
}
async fn sync_trigger(
&self,
params: SyncTriggerParams,
) -> Result<results::SyncTriggerResult, ControlError> {
let (store_id, root) = params.store.split_once(':').unwrap_or((¶ms.store, ""));
Ok(results::SyncTriggerResult {
store_id: store_id.into(),
root: root.into(),
status: "synced".into(),
size_bytes: 1,
served_root: root.into(),
})
}
async fn updater_status(&self) -> Result<Value, ControlError> {
Ok(json!({"channel": "stable"}))
}
async fn updater_set_channel(&self, params: SetChannelParams) -> Result<Value, ControlError> {
Ok(json!({"channel": params.channel}))
}
async fn updater_pause(&self, params: PauseParams) -> Result<Value, ControlError> {
Ok(json!({"paused": true, "until": params.until}))
}
async fn updater_resume(&self) -> Result<Value, ControlError> {
Ok(json!({"paused": false}))
}
async fn updater_check_now(&self) -> Result<Value, ControlError> {
Ok(json!({"checked": true}))
}
async fn pairing_list(&self) -> Result<Value, ControlError> {
Ok(json!({"pending": [], "tokens": []}))
}
async fn pairing_approve(
&self,
params: ApproveParams,
) -> Result<results::PairingApproveResult, ControlError> {
Ok(results::PairingApproveResult {
approved: true,
client_name: params.pairing_id,
token_id: "abcd1234".into(),
})
}
async fn pairing_revoke(
&self,
params: RevokeParams,
) -> Result<results::PairingRevokeResult, ControlError> {
Ok(results::PairingRevokeResult {
revoked: true,
token_id: params.token_id,
})
}
async fn peer_status(&self) -> Result<Value, ControlError> {
Ok(json!({"running": false}))
}
async fn peers_connect(
&self,
params: PeersConnectParams,
) -> Result<results::PeersConnectResult, ControlError> {
Ok(results::PeersConnectResult {
connected: true,
peer_id: params.peer,
})
}
async fn peers_disconnect(
&self,
params: PeersDisconnectParams,
) -> Result<results::PeersDisconnectResult, ControlError> {
Ok(results::PeersDisconnectResult {
disconnected: true,
peer_id: params.peer,
})
}
async fn chia_peers_add(
&self,
params: ChiaPeersAddParams,
) -> Result<results::ChiaPeersAddResult, ControlError> {
let ip = crate::params::canonical_peer_ip(¶ms.ip)?;
let trusted = ip != MOCK_BANNED_CHIA_PEER;
Ok(results::ChiaPeersAddResult {
added: true,
ip,
port: 8444,
corroboration_bypassed: trusted,
notice: if trusted {
MOCK_CORROBORATION_BYPASS_NOTICE.to_string()
} else {
MOCK_UNBANNED_WITHOUT_TRUST_NOTICE.to_string()
},
})
}
async fn chia_peers_list(&self) -> Result<results::ChiaPeersListResult, ControlError> {
Ok(results::ChiaPeersListResult {
peers: vec![
results::ChiaPeerEntry {
ip: MOCK_TRUSTED_CHIA_PEER.into(),
port: 8444,
peak_height: Some(6_000_010),
user_managed: true,
banned: false,
},
results::ChiaPeerEntry {
ip: "2001:db8::2".into(),
port: 8444,
peak_height: None,
user_managed: false,
banned: false,
},
],
})
}
async fn chia_peers_remove(
&self,
params: ChiaPeersRemoveParams,
) -> Result<results::ChiaPeersRemoveResult, ControlError> {
let ip = crate::params::canonical_peer_ip(¶ms.ip)?;
let outcome = if ip == MOCK_TRUSTED_CHIA_PEER {
results::ChiaPeerRemovalOutcome::Removed
} else {
results::ChiaPeerRemovalOutcome::NoSuchPeer
};
Ok(results::ChiaPeersRemoveResult {
outcome,
banned: params.ban && outcome == results::ChiaPeerRemovalOutcome::Removed,
ip,
})
}
async fn subscribe(
&self,
params: SubscribeParams,
) -> Result<results::SubscribeResult, ControlError> {
Ok(results::SubscribeResult {
subscribed: true,
added: true,
store_id: params.store_id,
kind: params.kind,
})
}
async fn unsubscribe(
&self,
params: UnsubscribeParams,
) -> Result<results::UnsubscribeResult, ControlError> {
Ok(results::UnsubscribeResult {
subscribed: false,
removed: true,
store_id: params.store_id,
})
}
async fn list_subscriptions(&self) -> Result<results::ListSubscriptionsResult, ControlError> {
Ok(results::ListSubscriptionsResult {
subscriptions: vec![],
count: 0,
})
}
async fn wallet_balance(
&self,
_params: WalletBalanceParams,
) -> Result<results::WalletBalanceResult, ControlError> {
Ok(results::WalletBalanceResult {
balance: 1234,
pending: 0,
source: Some(results::WalletReadSource::Db),
synced: true,
peak_height: Some(5_000_000),
})
}
async fn wallet_coins(
&self,
params: WalletCoinsParams,
) -> Result<results::WalletCoinsResult, ControlError> {
let amount = match params.asset {
Asset::Xch => 1,
a if a.is_dig() => 2,
Asset::Cat(_) => 3,
};
let remaining = ADDRESS_COINS
.iter()
.skip_while(|id| params.after_coin_id.as_deref().is_some_and(|a| **id <= a));
let limit = params.effective_limit() as usize;
let page: Vec<&str> = remaining.take(limit + 1).copied().collect();
let complete = page.len() <= limit;
let coins: Vec<results::WalletCoinRecord> = page
.into_iter()
.take(limit)
.map(|coin_id| results::WalletCoinRecord {
coin_id: coin_id.into(),
asset: Some(params.asset),
amount,
parent_coin_info: "11".repeat(32),
puzzle_hash: params.address.clone(),
created_height: Some(5_000_000),
spent_height: None,
})
.collect();
Ok(results::WalletCoinsResult {
cursor: coins.last().map(|c| c.coin_id.clone()),
coins,
complete: Some(complete),
source: Some(results::WalletReadSource::Db),
synced: true,
peak_height: Some(5_000_000),
})
}
async fn wallet_coin_by_id(
&self,
params: WalletCoinByIdParams,
) -> Result<results::WalletCoinByIdResult, ControlError> {
let coin = (params.coin_id == SPENT_COIN).then(|| results::WalletCoinRecord {
coin_id: SPENT_COIN.into(),
asset: None,
amount: 1_000_000_000_000,
parent_coin_info: "11".repeat(32),
puzzle_hash: "22".repeat(32),
created_height: Some(5_000_000),
spent_height: Some(5_000_042),
});
Ok(results::WalletCoinByIdResult {
coin,
source: Some(results::WalletReadSource::Fallback),
synced: false,
peak_height: None,
})
}
async fn wallet_coin_spend(
&self,
params: WalletCoinSpendParams,
) -> Result<results::WalletCoinSpendResult, ControlError> {
let spend = (params.coin_id == SPENT_COIN).then(|| results::WalletCoinSpend {
coin: results::WalletCoinRecord {
coin_id: SPENT_COIN.into(),
asset: None,
amount: 1_000_000_000_000,
parent_coin_info: "11".repeat(32),
puzzle_hash: "22".repeat(32),
created_height: Some(5_000_000),
spent_height: Some(5_000_042),
},
puzzle_reveal: REVEAL_HEX.into(),
solution: SOLUTION_HEX.into(),
});
Ok(results::WalletCoinSpendResult {
spend,
source: Some(results::WalletReadSource::Fallback),
synced: false,
peak_height: None,
})
}
async fn wallet_coins_by_parent(
&self,
params: WalletCoinsByParentParams,
) -> Result<results::WalletCoinsByParentResult, ControlError> {
let known: &[&str] = if params.parent_coin_id == SPENT_COIN {
&CHILD_COINS
} else {
&[]
};
let remaining = known
.iter()
.skip_while(|id| params.after_coin_id.as_deref().is_some_and(|a| **id <= a));
let limit = params.effective_limit() as usize;
let page: Vec<&str> = remaining.take(limit + 1).copied().collect();
let complete = page.len() <= limit;
let coins: Vec<results::WalletCoinRecord> = page
.into_iter()
.take(limit)
.map(|coin_id| results::WalletCoinRecord {
coin_id: coin_id.into(),
asset: None,
amount: 999_999_999_999,
parent_coin_info: SPENT_COIN.into(),
puzzle_hash: "33".repeat(32),
created_height: Some(5_000_042),
spent_height: None,
})
.collect();
Ok(results::WalletCoinsByParentResult {
cursor: coins.last().map(|c| c.coin_id.clone()),
coins,
complete,
source: Some(results::WalletReadSource::Fallback),
synced: false,
peak_height: None,
})
}
async fn wallet_arrivals(
&self,
params: WalletArrivalsParams,
) -> Result<results::WalletArrivalsResult, ControlError> {
let arrivals = vec![results::WalletArrivalRecord {
seq: 4_242,
coin_id: SPENT_COIN.into(),
puzzle_hash: "22".repeat(32),
amount: "1000000000000".into(),
asset_id: None,
confirmed_height: 5_000_000,
}];
let cursor = arrivals.last().map_or(params.after_seq, |a| a.seq);
Ok(results::WalletArrivalsResult {
arrivals,
cursor,
latest: 4_243,
})
}
async fn wallet_peak(&self) -> Result<results::WalletPeakResult, ControlError> {
Ok(results::WalletPeakResult {
peak_height: Some(5_000_000),
synced: true,
})
}
async fn wallet_operator_address(
&self,
) -> Result<results::WalletOperatorAddressResult, ControlError> {
Ok(results::WalletOperatorAddressResult::Known {
address: OPERATOR_ADDRESS.into(),
puzzle_hash: "7c".repeat(32),
})
}
async fn peer_counts(&self) -> Result<results::PeerCountsResult, ControlError> {
Ok(results::PeerCountsResult {
dig_peer_count: Some(6),
chia_peer_count: Some(3),
known_dig_peer_count: Some(41),
})
}
async fn wallet_sync_status(&self) -> Result<results::WalletSyncStatusResult, ControlError> {
Ok(results::WalletSyncStatusResult {
phase: results::WalletSyncPhase::Syncing,
peak_height: Some(4_999_000),
chia_peer_count: Some(3),
watched_addresses: Some(4),
subscription_peer_count: Some(1),
chia_peer_peak_height: Some(4_999_200),
})
}
async fn wallet_broadcast(
&self,
params: WalletBroadcastParams,
) -> Result<results::WalletBroadcastResult, ControlError> {
if params.signed_bundle_hex == REJECTED_BUNDLE {
return Ok(results::WalletBroadcastResult {
accepted: false,
transaction_id: None,
rejection: Some("DOUBLE_SPEND".into()),
});
}
Ok(results::WalletBroadcastResult {
accepted: true,
transaction_id: Some("cc".repeat(32)),
rejection: None,
})
}
async fn wallet_watch(
&self,
params: WalletWatchParams,
) -> Result<results::WalletWatchResult, ControlError> {
Ok(ENROLLED.with(|set| {
let mut set = set.borrow_mut();
let added = params
.public_keys
.into_iter()
.filter(|key| set.insert(key.clone()))
.count() as u32;
results::WalletWatchResult {
added,
watched: set.len() as u32,
}
}))
}
async fn wallet_unwatch(
&self,
params: WalletUnwatchParams,
) -> Result<results::WalletUnwatchResult, ControlError> {
Ok(ENROLLED.with(|set| {
let mut set = set.borrow_mut();
let removed = params
.public_keys
.iter()
.filter(|key| set.remove(*key))
.count() as u32;
results::WalletUnwatchResult {
removed,
watched: set.len() as u32,
}
}))
}
async fn wallet_watched(&self) -> Result<results::WalletWatchedResult, ControlError> {
Ok(ENROLLED.with(|set| results::WalletWatchedResult {
public_keys: set.borrow().iter().cloned().collect(),
}))
}
async fn wallet_reservations_held(
&self,
) -> Result<results::WalletReservationsHeldResult, ControlError> {
Ok(
RESERVATIONS.with(|table| results::WalletReservationsHeldResult {
reserved: table
.borrow()
.iter()
.filter(|(_, (_, expires))| *expires > MOCK_NOW_UNIX)
.flat_map(|(id, (coins, expires))| {
coins.iter().map(move |coin_id| results::ReservedCoin {
coin_id: coin_id.clone(),
reservation_id: id.clone(),
expires_at_unix: *expires,
})
})
.collect(),
as_of_unix: MOCK_NOW_UNIX,
}),
)
}
async fn wallet_reservations_reserve(
&self,
params: WalletReservationsReserveParams,
) -> Result<results::WalletReservationsReserveResult, ControlError> {
let params = params.validated()?;
let ttl_secs = params
.ttl_secs
.unwrap_or(MOCK_DEFAULT_TTL_SECS)
.min(MOCK_MAX_TTL_SECS);
RESERVATIONS.with(|table| {
let mut table = table.borrow_mut();
let held: std::collections::BTreeSet<&String> = table
.values()
.filter(|(_, expires)| *expires > MOCK_NOW_UNIX)
.flat_map(|(coins, _)| coins.iter())
.collect();
if let Some(clash) = params.coin_ids.iter().find(|id| held.contains(id)) {
return Err(ControlError::of(
ControlErrorCode::WalletCoinsReserved,
format!("coin {clash} is already reserved by an in-flight spend"),
));
}
let reservation_id = format!("mock-res-{}", table.len() + 1);
let expires_at_unix = MOCK_NOW_UNIX + ttl_secs;
table.insert(
reservation_id.clone(),
(params.coin_ids.clone(), expires_at_unix),
);
Ok(results::WalletReservationsReserveResult {
reservation_id,
coin_ids: params.coin_ids,
expires_at_unix,
ttl_secs,
})
})
}
async fn wallet_reservations_release(
&self,
params: WalletReservationsReleaseParams,
) -> Result<results::WalletReservationsReleaseResult, ControlError> {
Ok(RESERVATIONS.with(|table| {
let freed = table.borrow_mut().remove(¶ms.reservation_id);
match freed {
Some((coin_ids, _)) => results::WalletReservationsReleaseResult {
released: true,
coin_ids,
},
None => results::WalletReservationsReleaseResult {
released: false,
coin_ids: Vec::new(),
},
}
}))
}
async fn collateral_requirement(
&self,
) -> Result<results::CollateralRequirementResult, ControlError> {
Ok(results::CollateralRequirementResult::Known {
epoch: 7,
protocol_version: 1,
required_per_store_dig_base_units: 1_036,
stores: 4_200,
owners: 310,
multiplier_micros: 1_050_000,
handicap_dig_base_units: 2_760,
})
}
async fn mirror_bond_states(
&self,
params: crate::params::MirrorBondStatesParams,
) -> Result<results::MirrorBondStatesResult, ControlError> {
let entries = vec![
results::MirrorBondEntry {
store_id: STORE.into(),
root: ROOT.into(),
state: results::MirrorBondState::Bonded {
coin_id: BOND_COIN_A.into(),
epoch: 7,
amount_dig_base_units: 1_047,
},
},
results::MirrorBondEntry {
store_id: STORE.into(),
root: BOND_ROOT_B.into(),
state: results::MirrorBondState::Unfunded {
short_dig_base_units: 1_047,
},
},
];
let _ = params.effective_limit();
Ok(results::MirrorBondStatesResult::Known {
cursor: Some(results::MirrorBondKey {
store_id: STORE.into(),
root: BOND_ROOT_B.into(),
}),
entries,
complete: true,
locked_dig_base_units: 1_047,
epoch: 7,
})
}
async fn collateral_margin_get(&self) -> Result<results::CollateralMarginResult, ControlError> {
Ok(results::CollateralMarginResult {
margin_bp: MARGIN_BP.with(|m| *m.borrow()),
})
}
async fn collateral_margin_set(
&self,
params: crate::params::CollateralMarginSetParams,
) -> Result<results::CollateralMarginResult, ControlError> {
MARGIN_BP.with(|m| *m.borrow_mut() = params.margin_bp);
Ok(results::CollateralMarginResult {
margin_bp: params.margin_bp,
})
}
async fn collateral_buffer(&self) -> Result<results::CollateralBufferResult, ControlError> {
Ok(results::CollateralBufferResult::Known {
epoch: 7,
protocol_version: 1,
funding_state: results::CollateralFundingState::DangerouslyLow,
recommended_buffer_dig_base_units: 32_400,
spendable_dig_base_units: 14_050,
pairs_served_by_this_node: 12,
required_per_store_dig_base_units: 1_036,
margin_bp: MARGIN_BP.with(|m| *m.borrow()),
overlap_dig_base_units: 3_108,
escalation_headroom_dig_base_units: 7_468,
horizon_epochs: 4,
escalation_ceiling_micros: 1_601_806,
})
}
async fn spends_list(
&self,
params: SpendsListParams,
) -> Result<results::SpendsListResult, ControlError> {
Ok(audit_page(¶ms))
}
async fn profile_put_body(
&self,
params: ProfilePutBodyParams,
) -> Result<results::ProfilePutBodyResult, ControlError> {
Ok(results::ProfilePutBodyResult {
stored: true,
store_id: params.store_id,
root: params.root,
body_bytes: params.body_b64.len() as u64,
})
}
async fn profile_get_body(
&self,
params: ProfileGetBodyParams,
) -> Result<results::ProfileGetBodyResult, ControlError> {
Ok(results::ProfileGetBodyResult {
store_id: params.store_id,
root: params.root,
body_b64: None,
body_bytes: 0,
})
}
async fn pairing_request(
&self,
_params: RequestParams,
) -> Result<results::PairingRequestResult, ControlError> {
Ok(results::PairingRequestResult {
pairing_id: "pid-1".into(),
pairing_code: "012345".into(),
expires_ms: 1_700_000_000_000,
})
}
async fn pairing_poll(
&self,
_params: PollParams,
) -> Result<results::PairingPollResult, ControlError> {
Ok(results::PairingPollResult {
status: "pending".into(),
token: None,
})
}
}
fn unimpl(what: &str) -> ControlError {
ControlError::of(ControlErrorCode::NotSupported, format!("{what} not mocked"))
}
fn round_trip<C: crate::traits::ControlCall>(call: &C) -> Result<C::Output, ControlError> {
let node = MockNode;
let req = build_request(RequestId::Number(1), call);
let resp = block_on(node.dispatch(req));
parse_response::<C>(resp)
}
#[test]
fn dispatcher_routes_every_taking_params_method_to_its_typed_handler() {
assert!(round_trip(&StatusParams {}).unwrap().running);
assert_eq!(
round_trip(&SetCapParams { cap_bytes: 1 })
.unwrap()
.cap_bytes,
64 * 1024 * 1024,
"the node floors the cap"
);
assert!(round_trip(&CacheClearParams {}).unwrap().cleared);
let pin = round_trip(&PinParams {
store: STORE.into(),
})
.unwrap();
assert_eq!(pin.store_id, STORE);
let sync = round_trip(&SyncTriggerParams {
store: format!("{STORE}:{ROOT}"),
})
.unwrap();
assert_eq!(sync.root, ROOT);
let fetch_a = round_trip(&CapsuleFetchParams {
store: STORE.into(),
root: ROOT.into(),
})
.unwrap();
assert_eq!(
(fetch_a.store.as_str(), fetch_a.root.as_str()),
(STORE, ROOT)
);
let fetch_b = round_trip(&CapsuleFetchParams {
store: "otherstore".into(),
root: "otherroot".into(),
})
.unwrap();
assert_eq!(
(fetch_b.store.as_str(), fetch_b.root.as_str()),
("otherstore", "otherroot")
);
let sub = round_trip(&SubscribeParams {
store_id: STORE.into(),
kind: SubscriptionKind::Profile,
})
.unwrap();
assert!(sub.added);
assert_eq!(sub.kind, SubscriptionKind::Profile);
let conn = round_trip(&PeersConnectParams { peer: "p".into() }).unwrap();
assert_eq!(conn.peer_id, "p");
assert_eq!(
round_trip(&UpdaterStatusParams {}).unwrap(),
json!({"channel": "stable"})
);
assert_eq!(
round_trip(&PollParams {
pairing_id: "x".into()
})
.unwrap()
.status,
"pending"
);
}
#[test]
fn default_control_client_builds_and_parses_via_the_trait() {
use crate::traits::{ControlClient, DefaultControlClient};
let client = DefaultControlClient;
let req = client.build_request(RequestId::Number(9), &SetCapParams { cap_bytes: 5 });
assert_eq!(req.id, RequestId::Number(9));
assert_eq!(req.method, "control.cache.setCap");
let resp = JsonRpcResponse::success(RequestId::Number(9), json!({"cap_bytes": 5}));
let out = client
.parse_response::<SetCapParams>(resp)
.expect("typed parse");
assert_eq!(out.cap_bytes, 5);
}
#[test]
fn every_method_maps_to_a_category() {
use crate::method::Category;
assert_eq!(ControlMethod::Status.category(), Category::Status);
assert_eq!(ControlMethod::PeerStatus.category(), Category::Peers);
for &m in ControlMethod::ALL {
let _ = m.category();
}
}
#[test]
fn dispatcher_rejects_an_unknown_method_with_method_not_found() {
let node = MockNode;
let req = JsonRpcRequest::new(RequestId::Number(1), "control.nope", json!({}));
let resp = block_on(node.dispatch(req));
let err = resp.into_result().unwrap_err();
assert_eq!(err.code_enum(), Some(ControlErrorCode::MethodNotFound));
}
#[test]
fn dispatcher_maps_malformed_params_to_invalid_params() {
let node = MockNode;
let req = JsonRpcRequest::new(
RequestId::Number(1),
ControlMethod::CacheSetCap.name(),
json!({"cap_bytes": "not-a-number"}),
);
let resp = block_on(node.dispatch(req));
let err = resp.into_result().unwrap_err();
assert_eq!(err.code_enum(), Some(ControlErrorCode::InvalidParams));
}
#[test]
fn dispatcher_surfaces_a_handler_error_verbatim() {
let node = MockNode;
let req = build_request(
RequestId::Number(1),
&SetUpstreamParams {
upstream: UNSUPPORTED_UPSTREAM.into(),
},
);
let resp = block_on(node.dispatch(req));
let err = resp.into_result().unwrap_err();
assert_eq!(err.code_enum(), Some(ControlErrorCode::NotSupported));
}
#[test]
fn every_catalog_method_dispatches_without_panicking() {
let node = MockNode;
for &m in ControlMethod::ALL {
let req = JsonRpcRequest::new(RequestId::Number(1), m.name(), minimal_params(m));
let resp = block_on(node.dispatch(req));
assert!(
resp.error.is_none(),
"{} errored under minimal_params: {:?}",
m.name(),
resp.error
);
}
}
#[test]
fn peer_status_software_member_golden_vector() {
let snapshot = json!({
"connected": [
{
"peer_id": "aa00",
"address": "[2001:db8::1]:9444",
"outbound": true,
"software": {
"kind": "reported",
"product": "dig-node",
"version": "0.99.1",
"raw": "dig-node/0.99.1"
}
},
{
"peer_id": "bb11",
"address": "[2001:db8::2]:9444",
"outbound": false,
"software": { "kind": "unknown" }
}
]
});
let entries = snapshot["connected"].as_array().expect("connected array");
for entry in entries {
assert!(
entry.get("software").is_some(),
"every peerStatus entry must carry `software`; omitting it is a serialization bug, not an Unknown peer"
);
}
for entry in entries {
let wire = entry["software"].clone();
let parsed: results::PeerSoftware =
serde_json::from_value(wire.clone()).expect("software member must decode");
assert_eq!(
serde_json::to_value(&parsed).unwrap(),
wire,
"the software member is not byte-stable"
);
}
let reported: results::PeerSoftware =
serde_json::from_value(entries[0]["software"].clone()).unwrap();
assert_eq!(reported, results::PeerSoftware::parse("dig-node/0.99.1"));
let unknown: results::PeerSoftware =
serde_json::from_value(entries[1]["software"].clone()).unwrap();
assert_eq!(unknown, results::PeerSoftware::Unknown);
assert_ne!(reported, unknown);
}
#[test]
fn a_legacy_peer_entry_reads_as_unknown_not_as_version_zero() {
let software = results::PeerSoftware::parse("0.0.0");
assert_eq!(software, results::PeerSoftware::Unknown);
let wire = serde_json::to_value(&software).unwrap();
assert_eq!(wire, json!({"kind": "unknown"}));
assert_eq!(
wire.to_string().find("0.0.0"),
None,
"no rendering of a legacy peer may contain the sentinel as a version"
);
}
#[test]
fn a_conflicting_reserve_leaves_the_other_coins_free() {
let node = MockNode;
let first = block_on(
node.wallet_reservations_reserve(WalletReservationsReserveParams {
coin_ids: vec![RESERVE_COIN_B.into()],
ttl_secs: None,
}),
)
.expect("the first hold has nothing to clash with");
let clash = block_on(
node.wallet_reservations_reserve(WalletReservationsReserveParams {
coin_ids: vec![RESERVE_COIN_A.into(), RESERVE_COIN_B.into()],
ttl_secs: None,
}),
)
.expect_err("a coin already held must refuse the whole call");
assert_eq!(clash.data.code, "WALLET_COINS_RESERVED");
assert_eq!(clash.code, -32046);
let held = block_on(node.wallet_reservations_held()).unwrap();
let held_coins: Vec<&str> = held.reserved.iter().map(|r| r.coin_id.as_str()).collect();
assert_eq!(
held_coins,
vec![RESERVE_COIN_B],
"the refused call must have written nothing: coin A was taken by a partial reservation"
);
assert_eq!(held.reserved[0].reservation_id, first.reservation_id);
assert_eq!(held.as_of_unix, MOCK_NOW_UNIX);
}
#[test]
fn a_conflict_code_is_not_any_other_wallet_code() {
assert_eq!(
ControlErrorCode::WalletCoinsReserved.name(),
"WALLET_COINS_RESERVED"
);
assert_eq!(ControlErrorCode::WalletCoinsReserved.origin(), "node");
for &other in ControlErrorCode::ALL {
if other != ControlErrorCode::WalletCoinsReserved {
assert_ne!(other.code(), ControlErrorCode::WalletCoinsReserved.code());
assert_ne!(other.name(), ControlErrorCode::WalletCoinsReserved.name());
}
}
}
#[test]
fn every_catalogued_code_is_numerically_unique() {
for (i, &a) in ControlErrorCode::ALL.iter().enumerate() {
for &b in &ControlErrorCode::ALL[i + 1..] {
assert_ne!(
a.code(),
b.code(),
"{} and {} both claim {}",
a.name(),
b.name(),
a.code()
);
assert_ne!(
a.name(),
b.name(),
"two variants share the symbol {}",
a.name()
);
}
assert_eq!(
ControlErrorCode::from_code(a.code()),
Some(a),
"{} must resolve back to itself",
a.name()
);
}
}
#[test]
fn a_terminal_custody_refusal_and_a_transient_wait_never_share_a_code() {
assert_eq!(ControlErrorCode::WalletNodeSpendDisabled.code(), -32044);
assert_eq!(
ControlErrorCode::WalletNodeSpendDisabled.name(),
"WALLET_NODE_SPEND_DISABLED"
);
assert_eq!(ControlErrorCode::WalletCoinsReserved.code(), -32046);
assert_eq!(
ControlErrorCode::WalletReservationsUnavailable.code(),
-32047
);
assert_ne!(
ControlErrorCode::WalletNodeSpendDisabled.code(),
ControlErrorCode::WalletCoinsReserved.code()
);
assert!(
ControlErrorCode::WalletNodeSpendDisabled
.description()
.contains("Retrying cannot help"),
"the terminal code must say so: a caller reads the disposition, not the number"
);
}
#[test]
fn release_frees_the_hold_and_a_second_release_is_not_an_error() {
let node = MockNode;
let handle = block_on(
node.wallet_reservations_reserve(WalletReservationsReserveParams {
coin_ids: vec![RESERVE_COIN_A.into()],
ttl_secs: None,
}),
)
.unwrap();
assert_eq!(
block_on(node.wallet_reservations_held())
.unwrap()
.reserved
.len(),
1
);
let first = block_on(
node.wallet_reservations_release(WalletReservationsReleaseParams {
reservation_id: handle.reservation_id.clone(),
}),
)
.unwrap();
assert!(first.released);
assert_eq!(first.coin_ids, vec![RESERVE_COIN_A.to_string()]);
assert!(
block_on(node.wallet_reservations_held())
.unwrap()
.reserved
.is_empty(),
"the coin must be selectable again once its hold is released"
);
let again = block_on(
node.wallet_reservations_release(WalletReservationsReleaseParams {
reservation_id: handle.reservation_id,
}),
)
.expect("releasing an already-released handle is a success, not an error");
assert!(!again.released);
assert!(again.coin_ids.is_empty());
}
#[test]
fn a_requested_ttl_is_clamped_above_the_cap_and_honoured_below_it() {
let node = MockNode;
let over = block_on(
node.wallet_reservations_reserve(WalletReservationsReserveParams {
coin_ids: vec![RESERVE_COIN_A.into()],
ttl_secs: Some(MOCK_MAX_TTL_SECS + 1),
}),
)
.unwrap();
assert_eq!(
over.ttl_secs, MOCK_MAX_TTL_SECS,
"a hold longer than the cap must be clamped"
);
assert_eq!(over.expires_at_unix, MOCK_NOW_UNIX + MOCK_MAX_TTL_SECS);
let under_secs = MOCK_MAX_TTL_SECS - 1;
let under = block_on(
node.wallet_reservations_reserve(WalletReservationsReserveParams {
coin_ids: vec![RESERVE_COIN_B.into()],
ttl_secs: Some(under_secs),
}),
)
.unwrap();
assert_eq!(
under.ttl_secs, under_secs,
"a hold within the cap must be honoured as asked"
);
assert_eq!(under.expires_at_unix, MOCK_NOW_UNIX + under_secs);
}
#[test]
fn reserving_an_empty_list_yields_a_handle_that_holds_nothing() {
let node = MockNode;
let empty = block_on(
node.wallet_reservations_reserve(WalletReservationsReserveParams {
coin_ids: vec![],
ttl_secs: None,
}),
)
.expect("an empty selection is a legitimate no-op, not a malformed request");
assert!(empty.coin_ids.is_empty());
assert!(
block_on(node.wallet_reservations_held())
.unwrap()
.reserved
.is_empty(),
"an empty reservation must hold no coins"
);
let freed = block_on(
node.wallet_reservations_release(WalletReservationsReleaseParams {
reservation_id: empty.reservation_id,
}),
)
.unwrap();
assert!(freed.coin_ids.is_empty());
}
#[test]
fn golden_reservation_request_vectors() {
assert_request(
&WalletReservationsHeldParams {},
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.reservations.held","params":{}}),
);
assert_request(
&WalletReservationsReserveParams {
coin_ids: vec![RESERVE_COIN_A.into(), RESERVE_COIN_B.into()],
ttl_secs: Some(300),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.reservations.reserve",
"params":{"coin_ids":[RESERVE_COIN_A, RESERVE_COIN_B],"ttl_secs":300}}),
);
assert_request(
&WalletReservationsReserveParams {
coin_ids: vec![RESERVE_COIN_A.into()],
ttl_secs: None,
},
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.reservations.reserve",
"params":{"coin_ids":[RESERVE_COIN_A]}}),
);
assert_request(
&WalletReservationsReleaseParams {
reservation_id: "res-7".into(),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.reservations.release",
"params":{"reservation_id":"res-7"}}),
);
}
#[test]
fn reservation_requests_decode_from_foreign_wire_bytes() {
let prefixed: WalletReservationsReserveParams =
serde_json::from_value(json!({"coin_ids":[format!("0x{RESERVE_COIN_A}"), RESERVE_COIN_B]}))
.expect("a 0x-prefixed coin id is accepted on input");
assert_eq!(
prefixed.coin_ids,
vec![RESERVE_COIN_A.to_string(), RESERVE_COIN_B.to_string()],
"the prefix must be normalized away, never echoed"
);
assert_eq!(
prefixed.ttl_secs, None,
"an omitted ttl_secs decodes as absent"
);
let mixed = serde_json::from_value::<WalletReservationsReserveParams>(
json!({"coin_ids":[RESERVE_COIN_A, "not-a-coin-id"]}),
);
assert!(
mixed.is_err(),
"one malformed id must refuse the whole request"
);
assert!(serde_json::from_value::<WalletReservationsReserveParams>(
json!({"coin_ids":[RESERVE_COIN_A.to_ascii_uppercase()]})
)
.is_err());
let release: WalletReservationsReleaseParams =
serde_json::from_value(json!({"reservation_id":"res-7"})).unwrap();
assert_eq!(release.reservation_id, "res-7");
}
#[test]
fn golden_reservation_result_vectors() {
assert_result_round_trips::<results::WalletReservationsHeldResult>(json!({
"reserved": [
{"coin_id": RESERVE_COIN_A, "reservation_id": "res-1", "expires_at_unix": 1_800_000_300u64},
{"coin_id": RESERVE_COIN_B, "reservation_id": "res-2", "expires_at_unix": 1_800_000_600u64}
],
"as_of_unix": 1_800_000_000u64
}));
assert_result_round_trips::<results::WalletReservationsHeldResult>(json!({
"reserved": [],
"as_of_unix": 1_800_000_000u64
}));
assert_result_round_trips::<results::WalletReservationsReserveResult>(json!({
"reservation_id": "res-1",
"coin_ids": [RESERVE_COIN_A],
"expires_at_unix": 1_800_000_300u64,
"ttl_secs": 300u64
}));
assert_result_round_trips::<results::WalletReservationsReleaseResult>(json!({
"released": true,
"coin_ids": [RESERVE_COIN_A]
}));
assert_result_round_trips::<results::WalletReservationsReleaseResult>(json!({
"released": false,
"coin_ids": []
}));
}
fn minimal_params(m: ControlMethod) -> Value {
match m {
ControlMethod::ConfigSetUpstream => json!({"upstream": ""}),
ControlMethod::LogSetLevel => json!({"filter": "info"}),
ControlMethod::CacheSetCap => json!({"cap_bytes": 0}),
ControlMethod::HostedStoresPin
| ControlMethod::HostedStoresUnpin
| ControlMethod::HostedStoresStatus
| ControlMethod::SyncTrigger => json!({"store": STORE}),
ControlMethod::CapsuleFetch => json!({"store": STORE, "root": ROOT}),
ControlMethod::UpdaterSetChannel => json!({"channel": "stable"}),
ControlMethod::CollateralMarginSet => json!({"margin_bp": 100}),
ControlMethod::UpdaterPause => json!({}),
ControlMethod::PairingApprove => json!({"pairing_id": "x"}),
ControlMethod::PairingRevoke => json!({"token_id": "x"}),
ControlMethod::ChiaPeersAdd => json!({"ip": "203.0.113.7"}),
ControlMethod::ChiaPeersRemove => json!({"ip": "203.0.113.7"}),
ControlMethod::PeersConnect | ControlMethod::PeersDisconnect => json!({"peer": "p"}),
ControlMethod::Subscribe | ControlMethod::Unsubscribe => json!({"store_id": STORE}),
ControlMethod::PairingRequest => json!({"client_name": "c"}),
ControlMethod::PairingPoll => json!({"pairing_id": "x"}),
ControlMethod::WalletBalance | ControlMethod::WalletCoins => {
json!({"address": "xch1abc", "asset": "dig"})
}
ControlMethod::WalletCoinById | ControlMethod::WalletCoinSpend => {
json!({ "coin_id": ABSENT_COIN })
}
ControlMethod::WalletCoinsByParent => json!({ "parent_coin_id": ABSENT_COIN }),
ControlMethod::WalletBroadcast => json!({"signed_bundle_hex": "deadbeef"}),
ControlMethod::WalletWatch | ControlMethod::WalletUnwatch => {
json!({ "public_keys": [ENROL_KEY_A] })
}
ControlMethod::ProfilePutBody => {
json!({ "store_id": STORE, "root": ROOT, "body_b64": "" })
}
ControlMethod::ProfileGetBody => json!({ "store_id": STORE, "root": ROOT }),
ControlMethod::WalletReservationsReserve => json!({ "coin_ids": [] }),
ControlMethod::WalletReservationsRelease => json!({ "reservation_id": "mock-res-absent" }),
_ => json!({}),
}
}
#[test]
fn the_dispatcher_routes_each_wallet_chain_method_to_its_own_handler() {
let coins = round_trip(&WalletCoinsParams::first_page("xch1mintfunder", Asset::DIG))
.expect("coins must route");
assert_eq!(
coins.coins[0].puzzle_hash, "xch1mintfunder",
"the record echoes the ADDRESS it was asked for, so a mis-routed dispatch cannot pass"
);
assert_eq!(
coins.coins[0].asset,
Some(Asset::DIG),
"an address+asset read KNOWS the asset and must keep reporting it concretely -- `null` is reserved for a read that classified nothing"
);
assert_eq!(
coins.coins[0].amount, 2,
"the DIG amount proves the ASSET reached the handler, not just the address"
);
assert_eq!(
round_trip(&WalletPeakParams {})
.expect("peak must route")
.peak_height,
Some(5_000_000)
);
let sync = round_trip(&WalletSyncStatusParams {}).expect("syncStatus must route");
assert_eq!(sync.phase, results::WalletSyncPhase::Syncing);
assert_eq!(sync.peak_height, Some(4_999_000));
assert_eq!(sync.chia_peer_count, Some(3));
let counts = round_trip(&PeerCountsParams {}).expect("peerCounts must route");
assert_eq!(counts.dig_peer_count, Some(6));
assert_eq!(
counts.chia_peer_count,
Some(3),
"the same observation control.wallet.syncStatus reports"
);
let pushed = round_trip(&WalletBroadcastParams {
signed_bundle_hex: "deadbeef".into(),
})
.expect("broadcast must route");
assert!(pushed.accepted);
assert_eq!(pushed.rejection, None);
}
#[test]
fn the_dispatcher_routes_coin_by_id_to_its_own_handler() {
let found = round_trip(&WalletCoinByIdParams {
coin_id: SPENT_COIN.into(),
})
.expect("coinById must route");
let coin = found.coin.expect("the mock knows this coin");
assert_eq!(coin.coin_id, SPENT_COIN);
assert_eq!(
coin.spent_height,
Some(5_000_042),
"the spend height is the whole reason this method exists"
);
assert_eq!(
coin.asset, None,
"a by-id read classifies nothing; `null` says so rather than asserting a class"
);
}
#[test]
fn an_absent_coin_is_a_result_not_an_error() {
let absent = round_trip(&WalletCoinByIdParams {
coin_id: ABSENT_COIN.into(),
})
.expect("an unknown coin must arrive on the Ok channel, never as a control error");
assert_eq!(absent.coin, None);
let ok = JsonRpcResponse::success(RequestId::Number(1), serde_json::to_value(&absent).unwrap());
assert!(ok.error.is_none(), "an absent coin is not an error");
assert_eq!(ok.into_result().unwrap()["coin"], json!(null));
for code in [
ControlErrorCode::WalletNoChainSource,
ControlErrorCode::WalletReadFailed,
ControlErrorCode::WalletRateLimited,
] {
let failed =
JsonRpcResponse::error(RequestId::Number(1), ControlError::of(code, "unreachable"));
assert!(
parse_response::<WalletCoinByIdParams>(failed).is_err(),
"{} must not decode into an absent-coin result",
code.name()
);
}
}
#[test]
fn a_malformed_coin_id_is_invalid_params_not_an_absent_coin() {
for bad in [
"",
"ab", &"ab".repeat(33), &"AB".repeat(32), &format!("{}zz", "ab".repeat(31)), &format!("0x{}", "ab".repeat(31)), &format!(" {} ", "ab".repeat(32)), ] {
let node = MockNode;
let req = JsonRpcRequest::new(
RequestId::Number(1),
ControlMethod::WalletCoinById.name(),
json!({ "coin_id": bad }),
);
let err = block_on(node.dispatch(req)).into_result().unwrap_err();
assert_eq!(
err.code_enum(),
Some(ControlErrorCode::InvalidParams),
"{bad:?} must be refused as malformed params"
);
}
let node = MockNode;
let req = JsonRpcRequest::new(
RequestId::Number(1),
ControlMethod::WalletCoinById.name(),
json!({ "coin_id": format!("0x{SPENT_COIN}") }),
);
let found: results::WalletCoinByIdResult =
serde_json::from_value(block_on(node.dispatch(req)).into_result().unwrap()).unwrap();
assert_eq!(
found
.coin
.expect("the 0x form names a coin the mock knows")
.coin_id,
SPENT_COIN,
"the prefix is normalized away and never emitted"
);
}
#[test]
fn an_omitted_coin_field_is_a_decode_error_not_a_null_verdict() {
let stated = serde_json::from_value::<results::WalletCoinByIdResult>(json!({
"coin": null, "source": "fallback", "synced": false, "peak_height": null
}))
.expect("an explicitly null coin is a valid verdict");
assert_eq!(stated.coin, None);
for wire in [
json!({ "source": "fallback", "synced": false, "peak_height": null }),
json!({ "synced": false }),
json!({ "peak_height": 5_000_000u32, "synced": true }),
json!({ "coins": [], "source": "db", "synced": true, "peak_height": 5_000_000u32 }),
] {
assert!(
serde_json::from_value::<results::WalletCoinByIdResult>(wire.clone()).is_err(),
"a payload without a `coin` key must not decode into a no-such-coin verdict: {wire}"
);
}
}
#[test]
fn the_dispatcher_routes_the_spend_and_the_children_to_their_own_handlers() {
let found = round_trip(&WalletCoinSpendParams {
coin_id: SPENT_COIN.into(),
})
.expect("coinSpend must route");
let spend = found.spend.expect("the mock knows this coin's spend");
assert_eq!(spend.coin.coin_id, SPENT_COIN);
assert_eq!(
spend.puzzle_reveal, REVEAL_HEX,
"the reveal is the half a coin record cannot supply, and is why this method exists"
);
assert_eq!(
spend.solution, SOLUTION_HEX,
"reveal and solution hold DIFFERENT values, so a transposition cannot pass here"
);
assert!(
spend.coin.spent_height.is_some(),
"a spend exists only because the coin was spent -- an unspent coin here is a contradiction"
);
let children = round_trip(&WalletCoinsByParentParams::first_page(SPENT_COIN))
.expect("coinsByParent must route");
assert_eq!(children.coins.len(), CHILD_COINS.len());
assert_eq!(
children.coins[0].coin_id, CHILD_COINS[0],
"the answer is the CHILD -- a handler echoing the parent it was asked about fails here"
);
assert_eq!(
children.coins[0].parent_coin_info, SPENT_COIN,
"and the child names the parent that was asked for, so the id reached the handler"
);
assert_eq!(
children.coins[0].asset, None,
"naming a coin by its parent classifies nothing; `null` says so rather than asserting"
);
}
#[test]
fn an_absent_spend_and_a_childless_parent_are_answers_not_errors() {
let no_spend = round_trip(&WalletCoinSpendParams {
coin_id: ABSENT_COIN.into(),
})
.expect("an absent spend is a SUCCESS -- the error channel is reserved for could-not-answer");
assert_eq!(no_spend.spend, None);
let no_children = round_trip(&WalletCoinsByParentParams::first_page(ABSENT_COIN))
.expect("a childless parent is a SUCCESS, for the same reason an empty `.coins` is");
assert!(no_children.coins.is_empty());
assert!(
no_children.complete,
"a childless parent is COMPLETELY answered -- nothing was withheld"
);
assert_eq!(
no_children.cursor, None,
"an empty page hands back nothing, so there is nothing to resume from"
);
assert!(round_trip(&WalletCoinSpendParams {
coin_id: SPENT_COIN.into()
})
.unwrap()
.spend
.is_some());
assert!(
!round_trip(&WalletCoinsByParentParams::first_page(SPENT_COIN))
.unwrap()
.coins
.is_empty()
);
}
#[test]
fn an_omitted_spend_field_is_a_decode_error_not_a_no_spend_verdict() {
let stated = serde_json::from_value::<results::WalletCoinSpendResult>(json!({
"spend": null, "source": "fallback", "synced": false, "peak_height": null
}))
.expect("an explicitly null spend is a valid verdict");
assert_eq!(stated.spend, None);
for wire in [
json!({ "source": "fallback", "synced": false, "peak_height": null }),
json!({ "synced": false }),
json!({ "peak_height": 5_000_000u32, "synced": true }),
json!({ "coins": [], "source": "db", "synced": true, "peak_height": 5_000_000u32 }),
] {
assert!(
serde_json::from_value::<results::WalletCoinSpendResult>(wire.clone()).is_err(),
"a payload without a `spend` key must not decode into a never-spent verdict: {wire}"
);
}
}
#[test]
fn wallet_coins_by_parent_params_enforce_the_coin_id_rule_on_their_own_field() {
for bad in [
json!({"parent_coin_id": "AB".repeat(32)}),
json!({"parent_coin_id": "abc"}),
] {
assert!(
serde_json::from_value::<WalletCoinsByParentParams>(bad.clone()).is_err(),
"a malformed parent id must be refused at deserialization: {bad}"
);
}
assert!(
serde_json::from_value::<WalletCoinsByParentParams>(json!({"coin_id": "ab".repeat(32)}))
.is_err(),
"the by-parent read is asked with `parent_coin_id`; `coin_id` is a different question"
);
let prefixed = serde_json::from_value::<WalletCoinsByParentParams>(
json!({"parent_coin_id": format!("0x{}", "ab".repeat(32))}),
)
.expect("a 0x-prefixed id is tolerated on input");
assert_eq!(
prefixed.parent_coin_id,
"ab".repeat(32),
"the prefix is normalized away and never emitted"
);
}
#[test]
fn the_coin_page_and_the_final_page_are_told_apart_by_complete_not_by_length() {
let first = round_trip(&WalletCoinsParams {
address: "xch1funded".into(),
asset: Asset::DIG,
after_coin_id: None,
limit: Some(2),
})
.expect("a bounded first page must route");
assert_eq!(first.coins.len(), 2);
assert_eq!(
first.complete,
Some(false),
"two of four coins were withheld -- reporting this page as complete presents a partial \
coin set as the whole one"
);
assert_eq!(
first.cursor.as_deref(),
Some(ADDRESS_COINS[1]),
"the cursor is the last coin actually HANDED over, never a chain-head marker"
);
let second = round_trip(&WalletCoinsParams {
address: "xch1funded".into(),
asset: Asset::DIG,
after_coin_id: first.cursor.clone(),
limit: Some(2),
})
.expect("resuming from the handed-back cursor must route");
assert_eq!(
second.coins.len(),
first.coins.len(),
"both pages carry the same row count -- which is exactly why length cannot decide \
completeness"
);
assert_eq!(
second.complete,
Some(true),
"the last two coins fit, so this page IS the end of the set"
);
let walked: Vec<&str> = first
.coins
.iter()
.chain(second.coins.iter())
.map(|c| c.coin_id.as_str())
.collect();
assert_eq!(walked, ADDRESS_COINS.to_vec());
}
#[test]
fn the_coin_page_bound_is_refused_out_of_range_rather_than_clamped() {
let at_max = serde_json::from_value::<WalletCoinsParams>(json!({
"address": "xch1funded", "asset": "xch", "limit": COINS_MAX_LIMIT
}))
.expect("the documented maximum must be ACCEPTED, or the constant is not the real bound");
assert_eq!(at_max.effective_limit(), COINS_MAX_LIMIT);
for over in [COINS_MAX_LIMIT + 1, u32::MAX, 0] {
let wire = json!({"address": "xch1funded", "asset": "xch", "limit": over});
assert!(
serde_json::from_value::<WalletCoinsParams>(wire).is_err(),
"limit {over} must be REFUSED, never clamped: a silently shrunk page hands back a \
cursor for a position the caller never asked about"
);
assert!(WalletCoinsParams {
address: "xch1funded".into(),
asset: Asset::Xch,
after_coin_id: None,
limit: Some(over),
}
.validated()
.is_err());
}
assert_eq!(
WalletCoinsParams::first_page("xch1funded", Asset::Xch).effective_limit(),
COINS_DEFAULT_LIMIT
);
assert!(serde_json::from_value::<WalletCoinsParams>(
json!({"address": "xch1funded", "asset": "xch", "after_coin_id": "AB".repeat(32)})
)
.is_err());
let prefixed = serde_json::from_value::<WalletCoinsParams>(
json!({"address": "xch1funded", "asset": "xch", "after_coin_id": format!("0x{}", "ab".repeat(32))}),
)
.expect("an 0x-prefixed cursor is tolerated on input");
assert_eq!(prefixed.after_coin_id.as_deref(), Some(&*"ab".repeat(32)));
}
#[test]
fn an_unpaged_answer_from_an_older_node_is_not_read_as_a_truncated_page() {
let legacy = serde_json::from_value::<results::WalletCoinsResult>(json!({
"coins": [{
"coin_id": "aa".repeat(32), "asset": "xch", "amount": 1_750_000_000_000u64,
"parent_coin_info": "bb".repeat(32), "puzzle_hash": "cc".repeat(32),
"created_height": 5_000_000u32, "spent_height": null
}],
"source": "db", "synced": true, "peak_height": 5_000_000u32
}))
.expect("a pre-0.25 node's answer must still decode");
assert_eq!(
legacy.complete, None,
"an absent `complete` is UNDISCLOSED -- reading it as `Some(false)` sends a caller \
resuming into a node that never paged"
);
assert_eq!(legacy.cursor, None);
assert_eq!(legacy.coins.len(), 1, "the coins themselves still decode");
let truncated = round_trip(&WalletCoinsParams {
address: "xch1funded".into(),
asset: Asset::DIG,
after_coin_id: None,
limit: Some(2),
})
.expect("a paged first page must route");
assert_eq!(truncated.complete, Some(false));
assert_ne!(
truncated.complete, legacy.complete,
"undisclosed and truncated MUST NOT be the same value"
);
}
#[test]
fn a_truncated_page_and_a_final_page_are_told_apart_by_complete_not_by_length() {
let first = round_trip(&WalletCoinsByParentParams {
parent_coin_id: SPENT_COIN.into(),
after_coin_id: None,
limit: Some(2),
})
.expect("a bounded first page must route");
assert_eq!(first.coins.len(), 2);
assert!(
!first.complete,
"two of four children were withheld -- reporting this page as complete ends the walk early"
);
assert_eq!(
first.cursor.as_deref(),
Some(CHILD_COINS[1]),
"the cursor is the last child actually HANDED over, never a chain-head marker"
);
let second = round_trip(&WalletCoinsByParentParams {
parent_coin_id: SPENT_COIN.into(),
after_coin_id: first.cursor.clone(),
limit: Some(2),
})
.expect("resuming from the handed-back cursor must route");
assert_eq!(
second.coins.len(),
first.coins.len(),
"both pages carry the same row count -- which is exactly why length cannot decide completeness"
);
assert!(
second.complete,
"the last two children fit, so this page IS the end of the hop"
);
let walked: Vec<&str> = first
.coins
.iter()
.chain(second.coins.iter())
.map(|c| c.coin_id.as_str())
.collect();
assert_eq!(walked, CHILD_COINS.to_vec());
}
#[test]
fn the_page_bound_is_refused_out_of_range_rather_than_clamped() {
let at_max = serde_json::from_value::<WalletCoinsByParentParams>(json!({
"parent_coin_id": "ab".repeat(32), "limit": COINS_BY_PARENT_MAX_LIMIT
}))
.expect("the documented maximum must be ACCEPTED, or the constant is not the real bound");
assert_eq!(at_max.limit, Some(COINS_BY_PARENT_MAX_LIMIT));
assert_eq!(at_max.effective_limit(), COINS_BY_PARENT_MAX_LIMIT);
for over in [COINS_BY_PARENT_MAX_LIMIT + 1, u32::MAX, 0] {
let wire = json!({"parent_coin_id": "ab".repeat(32), "limit": over});
assert!(
serde_json::from_value::<WalletCoinsByParentParams>(wire).is_err(),
"limit {over} must be REFUSED, never clamped: a silently shrunk page hands back a cursor for a position the caller never asked about"
);
assert!(WalletCoinsByParentParams {
parent_coin_id: "ab".repeat(32),
after_coin_id: None,
limit: Some(over),
}
.validated()
.is_err());
}
assert_eq!(
WalletCoinsByParentParams::first_page("ab".repeat(32)).effective_limit(),
COINS_BY_PARENT_DEFAULT_LIMIT
);
}
#[test]
fn the_largest_legal_page_fits_inside_the_transport_frame() {
const MAX_FRAME_BYTES: usize = 1024 * 1024;
let worst_case = serde_json::to_string(&results::WalletCoinRecord {
coin_id: "f".repeat(64),
asset: None,
amount: u64::MAX,
parent_coin_info: "f".repeat(64),
puzzle_hash: "f".repeat(64),
created_height: Some(u32::MAX),
spent_height: Some(u32::MAX),
})
.unwrap()
.len()
+ 1;
let largest_page = worst_case * COINS_BY_PARENT_MAX_LIMIT as usize;
assert!(
largest_page * 3 < MAX_FRAME_BYTES,
"the maximum page ({largest_page} B) must fit the 1 MiB frame with 3x headroom for the envelope and any future additive field; raising the cap past that puts a conforming node's honest answer beyond what the transport can deliver"
);
}
#[test]
fn an_omitted_cursor_field_is_a_decode_error_not_an_empty_page() {
let empty = serde_json::from_value::<results::WalletCoinsByParentResult>(json!({
"coins": [], "complete": true, "cursor": null,
"source": "fallback", "synced": false, "peak_height": null
}))
.expect("an explicitly null cursor is a valid empty page");
assert_eq!(empty.cursor, None);
for wire in [
json!({ "coins": [], "complete": true, "source": "fallback", "synced": false,
"peak_height": null }),
json!({ "coins": [], "source": "db", "synced": true, "peak_height": 5_000_000u32 }),
] {
assert!(
serde_json::from_value::<results::WalletCoinsByParentResult>(wire.clone()).is_err(),
"a payload without a `cursor` key must not decode into a nothing-to-resume-from \
page: {wire}"
);
}
assert!(
serde_json::from_value::<results::WalletCoinsByParentResult>(json!({
"coins": [], "cursor": null, "source": "fallback", "synced": false,
"peak_height": null
}))
.is_err(),
"a payload without `complete` must not decode into a claim that the page is whole"
);
}
#[test]
fn a_by_parent_request_carries_its_page_bound() {
let params: WalletCoinsByParentParams =
serde_json::from_value(json!({"parent_coin_id": "ab".repeat(32), "limit": 5}))
.expect("a bounded request must decode");
let wire = serde_json::to_value(¶ms).unwrap();
assert_eq!(
wire["limit"],
json!(5),
"a page bound the caller asked for must not be discarded on the way to the node"
);
}
#[test]
fn wallet_coin_by_id_params_refuse_malformed_ids_at_deserialization() {
let bad_id = "AB".repeat(32);
let upper = serde_json::from_value::<WalletCoinByIdParams>(json!({"coin_id": bad_id}));
assert!(
upper.is_err(),
"uppercase ids must fail during deserialization"
);
}
#[test]
fn a_mempool_refusal_arrives_as_a_value_not_as_an_error() {
let outcome = round_trip(&WalletBroadcastParams {
signed_bundle_hex: REJECTED_BUNDLE.into(),
})
.expect("a refusal must arrive on the Ok channel, never as a control error");
assert!(!outcome.accepted);
assert_eq!(outcome.rejection.as_deref(), Some("DOUBLE_SPEND"));
assert_eq!(
outcome.transaction_id, None,
"a refused bundle has no transaction to report"
);
}
#[test]
fn dig_apps_frozen_engine_shapes_deserialize_our_wallet_results() {
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq)]
struct AppCoinRecord {
coin_id: String,
asset: Asset,
amount: u64,
}
#[derive(Debug, Deserialize)]
struct AppCoinsResponse {
coins: Vec<AppCoinRecord>,
}
#[derive(Debug, Deserialize)]
struct AppBroadcastResponse {
accepted: bool,
#[serde(default)]
transaction_id: Option<String>,
}
let coins = serde_json::to_value(results::WalletCoinsResult {
coins: vec![results::WalletCoinRecord {
coin_id: "aa".repeat(32),
asset: Some(Asset::DIG),
amount: 2_000_000_000_000,
parent_coin_info: "bb".repeat(32),
puzzle_hash: "cc".repeat(32),
created_height: Some(5_000_000),
spent_height: None,
}],
complete: Some(true),
cursor: Some("aa".repeat(32)),
source: Some(results::WalletReadSource::Db),
synced: true,
peak_height: Some(5_000_000),
})
.unwrap();
let read: AppCoinsResponse =
serde_json::from_value(coins).expect("dig-app must read our coins");
assert_eq!(
read.coins,
vec![AppCoinRecord {
coin_id: "aa".repeat(32),
asset: Asset::DIG,
amount: 2_000_000_000_000,
}]
);
let pushed = serde_json::to_value(results::WalletBroadcastResult {
accepted: true,
transaction_id: Some("dd".repeat(32)),
rejection: None,
})
.unwrap();
let read: AppBroadcastResponse =
serde_json::from_value(pushed).expect("dig-app must read our broadcast outcome");
assert!(read.accepted);
assert_eq!(read.transaction_id, Some("dd".repeat(32)));
}
#[test]
fn dig_apps_frozen_coin_shape_rejects_null_asset_in_wallet_coins() {
use serde::Deserialize;
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct AppCoinRecord {
coin_id: String,
asset: Asset,
amount: u64,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct AppCoinsResponse {
coins: Vec<AppCoinRecord>,
}
let coins = serde_json::to_value(results::WalletCoinsResult {
coins: vec![results::WalletCoinRecord {
coin_id: "aa".repeat(32),
asset: None,
amount: 2_000_000_000_000,
parent_coin_info: "bb".repeat(32),
puzzle_hash: "cc".repeat(32),
created_height: Some(5_000_000),
spent_height: None,
}],
complete: Some(true),
cursor: Some("aa".repeat(32)),
source: Some(results::WalletReadSource::Db),
synced: true,
peak_height: Some(5_000_000),
})
.unwrap();
assert!(
serde_json::from_value::<AppCoinsResponse>(coins).is_err(),
"dig-app's frozen coin shape requires a non-null asset in control.wallet.coins"
);
}
#[test]
fn the_wallet_failure_codes_match_the_nodes_catalogue() {
for (code, number, symbol) in [
(
ControlErrorCode::WalletNoChainSource,
-32040,
"WALLET_NO_CHAIN_SOURCE",
),
(
ControlErrorCode::WalletNotSynced,
-32041,
"WALLET_NOT_SYNCED",
),
(
ControlErrorCode::WalletReadFailed,
-32042,
"WALLET_READ_FAILED",
),
(
ControlErrorCode::WalletRateLimited,
-32043,
"WALLET_RATE_LIMITED",
),
] {
assert_eq!(code.code(), number, "{symbol} number drifted");
assert_eq!(code.name(), symbol);
assert_eq!(ControlErrorCode::from_code(number), Some(code));
assert_eq!(
code.origin(),
"node",
"a wallet read is served by the node's backend, not the shell"
);
}
}
#[test]
fn the_arrival_cursor_wire_shapes_are_byte_stable() {
assert_request(
&WalletArrivalsParams {
after_seq: 41,
limit: Some(10),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.arrivals","params":{"after_seq":41,"limit":10}}),
);
assert_request(
&WalletArrivalsParams {
after_seq: 0,
limit: None,
},
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.arrivals","params":{"after_seq":0}}),
);
assert_result_round_trips::<results::WalletArrivalsResult>(json!({
"arrivals": [{
"seq": 7u64,
"coin_id": "ab".repeat(32),
"puzzle_hash": "cc".repeat(32),
"amount": "18446744073709551615",
"asset_id": null,
"confirmed_height": 5_000_000u32
}],
"cursor": 7u64,
"latest": 9u64
}));
assert_result_round_trips::<results::WalletArrivalsResult>(json!({
"arrivals": [{
"seq": 8u64,
"coin_id": "ab".repeat(32),
"puzzle_hash": "cc".repeat(32),
"amount": "2500",
"asset_id": "a406d3".to_string(),
"confirmed_height": 5_000_001u32
}],
"cursor": 8u64,
"latest": 8u64
}));
assert_result_round_trips::<results::WalletArrivalsResult>(json!({
"arrivals": [], "cursor": 41u64, "latest": 41u64
}));
}
#[test]
fn the_arrival_page_cursor_is_the_last_row_handed_over_not_the_ledger_head() {
let ahead: results::WalletArrivalsResult = serde_json::from_value(json!({
"arrivals": [{
"seq": 5u64, "coin_id": "ab".repeat(32), "puzzle_hash": "cc".repeat(32),
"amount": "1", "asset_id": null, "confirmed_height": 100u32
}],
"cursor": 5u64,
"latest": 12u64
}))
.expect("a page whose ledger has moved on is a valid answer");
assert_eq!(
ahead.cursor, 5,
"the cursor must be the last row handed over"
);
assert_eq!(ahead.latest, 12, "the ledger head must survive the decode");
assert_eq!(ahead.arrivals[0].amount, "1");
}
#[test]
fn the_arrival_cursor_is_gated_and_routes_to_its_own_handler() {
assert!(
ControlMethod::WalletArrivals.requires_auth(),
"the arrival cursor volunteers this node's own watched puzzle hashes, so it is gated \
unlike the caller-addressed wallet reads"
);
let page = round_trip(&WalletArrivalsParams {
after_seq: 0,
limit: None,
})
.expect("arrivals must route");
assert_eq!(page.arrivals[0].seq, 4_242);
assert_eq!(page.cursor, 4_242);
assert_eq!(page.latest, 4_243, "the ledger head must not be the cursor");
}
#[test]
fn an_unknown_phase_token_deserializes_instead_of_erroring() {
let parsed =
serde_json::from_value::<results::WalletSyncPhase>(json!("a_phase_from_a_newer_node"))
.expect("an unrecognised phase token must parse, not abort the response");
assert_ne!(parsed, results::WalletSyncPhase::NotStarted);
assert_ne!(parsed, results::WalletSyncPhase::Syncing);
assert_ne!(
parsed,
results::WalletSyncPhase::Synced,
"an unknown token coerced into a KNOWN phase is worse than the parse error it replaced: it \
states a sync fact the node never sent"
);
}
#[test]
fn an_unknown_phase_token_does_not_kill_the_surrounding_result() {
let parsed = serde_json::from_value::<results::WalletSyncStatusResult>(json!({
"phase": "a_phase_from_a_newer_node",
"peak_height": 5_000_000u32,
"chia_peer_count": 3u32
}))
.expect("the unknown token must degrade the phase alone, not the whole result");
assert_eq!(parsed.peak_height, Some(5_000_000));
assert_eq!(parsed.chia_peer_count, Some(3));
}
#[test]
fn an_unrecognized_phase_carries_the_token_it_did_not_understand() {
let parsed =
serde_json::from_value::<results::WalletSyncPhase>(json!("a_phase_from_a_newer_node"))
.unwrap();
assert_eq!(
parsed.unrecognized_token(),
Some("a_phase_from_a_newer_node")
);
assert!(!parsed.is_recognized());
assert_eq!(parsed.as_wire(), "a_phase_from_a_newer_node");
assert_eq!(
serde_json::to_value(&parsed).unwrap(),
json!("a_phase_from_a_newer_node"),
"re-encoding must hand back the node's own token, never a placeholder"
);
for phase in results::WalletSyncPhase::ALL {
assert_eq!(phase.unrecognized_token(), None, "{phase:?}");
assert!(phase.is_recognized(), "{phase:?}");
}
}
#[test]
fn a_non_string_phase_is_still_a_type_error() {
for malformed in [
json!(3),
json!(null),
json!({"phase": "synced"}),
json!(["synced"]),
] {
assert!(
serde_json::from_value::<results::WalletSyncPhase>(malformed.clone()).is_err(),
"{malformed} is a malformed phase, not an unrecognised one"
);
}
}
#[test]
fn a_payload_without_watched_addresses_parses_as_unreported() {
let legacy = serde_json::from_value::<results::WalletSyncStatusResult>(json!({
"phase": "synced", "peak_height": 5_000_000u32, "chia_peer_count": 5u32
}))
.expect("a node predating the field must stay readable");
assert_eq!(
legacy.watched_addresses, None,
"absent means unreported; Some(0) would be a measurement the node never made"
);
assert_eq!(legacy.phase, results::WalletSyncPhase::Synced);
assert_eq!(legacy.peak_height, Some(5_000_000));
let explicit_null = serde_json::from_value::<results::WalletSyncStatusResult>(json!({
"phase": "synced", "peak_height": 5_000_000u32, "chia_peer_count": 5u32,
"watched_addresses": null
}))
.unwrap();
assert_eq!(explicit_null, legacy);
}
#[test]
fn a_payload_without_the_peer_fields_parses_as_unreported() {
let legacy = serde_json::from_value::<results::WalletSyncStatusResult>(json!({
"phase": "syncing", "peak_height": 9_132_747u32, "chia_peer_count": 5u32,
"watched_addresses": 0u32
}))
.expect("a node predating these fields must stay readable");
assert_eq!(
legacy.subscription_peer_count, None,
"absent means unreported; Some(0) would claim no supervisor is attached, a stronger claim \
than the node made"
);
assert_eq!(
legacy.chia_peer_peak_height, None,
"absent means unreported; Some(0) would be a real height, not the unobserved state"
);
assert_eq!(legacy.chia_peer_count, Some(5));
let current = serde_json::from_value::<results::WalletSyncStatusResult>(json!({
"phase": "syncing", "peak_height": 9_132_747u32, "chia_peer_count": 5u32,
"subscription_peer_count": 1u32, "chia_peer_peak_height": 9_140_469u32,
"watched_addresses": 0u32
}))
.expect("the current wire shape must parse");
assert_eq!(current.subscription_peer_count, Some(1));
assert_eq!(current.chia_peer_peak_height, Some(9_140_469));
assert_ne!(
current.chia_peer_count, current.subscription_peer_count,
"the two fields are different observations and must not collapse to the same reading"
);
}
#[test]
fn phase_is_required_and_absent_counts_decode_as_unreported() {
let mut without_phase = json!({
"peak_height": 5_000_000u32, "chia_peer_count": 5u32, "watched_addresses": 12u32
});
assert!(
serde_json::from_value::<results::WalletSyncStatusResult>(without_phase.take()).is_err(),
"a response with no phase is malformed — there is no honest default for it"
);
for missing in ["peak_height", "chia_peer_count", "watched_addresses"] {
let mut payload = json!({
"phase": "synced", "peak_height": 5_000_000u32, "chia_peer_count": 5u32,
"watched_addresses": 12u32
});
payload.as_object_mut().unwrap().remove(missing);
let parsed = serde_json::from_value::<results::WalletSyncStatusResult>(payload)
.unwrap_or_else(|e| panic!("`{missing}` absent must stay readable: {e}"));
let decoded = serde_json::to_value(&parsed).unwrap();
assert_eq!(
decoded[missing],
json!(null),
"`{missing}` absent must decode as unreported, never as a measured zero"
);
}
}
#[test]
fn no_wallet_enrolled_is_distinguishable_from_a_wallet_that_is_not_unlocked() {
let no_wallet = results::WalletSyncStatusResult {
phase: results::WalletSyncPhase::NoWalletEnrolled,
peak_height: None,
chia_peer_count: Some(2),
watched_addresses: Some(0),
subscription_peer_count: None,
chia_peer_peak_height: None,
};
let locked = results::WalletSyncStatusResult {
phase: results::WalletSyncPhase::WalletNotUnlocked,
..no_wallet.clone()
};
assert_ne!(no_wallet, locked);
assert_ne!(
serde_json::to_value(&no_wallet).unwrap(),
serde_json::to_value(&locked).unwrap(),
"an enrolled-but-unwatched wallet must not wear the all-clear's wire shape"
);
assert_eq!(
serde_json::to_value(&locked).unwrap()["phase"],
json!("wallet_not_unlocked")
);
}
#[test]
fn only_a_complete_picture_may_render_as_settled() {
let expected = [
(results::WalletSyncPhase::NotStarted, false),
(results::WalletSyncPhase::Syncing, false),
(results::WalletSyncPhase::Synced, true),
(results::WalletSyncPhase::NoWalletEnrolled, true),
(results::WalletSyncPhase::WalletNotUnlocked, false),
];
for (phase, settled) in &expected {
assert_eq!(
phase.may_render_as_settled(),
*settled,
"{phase:?} is on the wrong side of the settled line"
);
}
assert_eq!(
expected.len(),
results::WalletSyncPhase::ALL.len(),
"every known phase must be classified here"
);
for unknown in ["a_newer_token", "", "settled", "no_addresses_to_watch"] {
assert!(
!results::WalletSyncPhase::from(unknown).may_render_as_settled(),
"{unknown:?} must not be settled — this build cannot know what it means"
);
}
}
#[test]
fn a_token_cannot_forge_a_log_line() {
let hostile = "\u{1b}[2K\rsynced\u{202e}";
let phase = results::WalletSyncPhase::from(hostile);
let token = phase
.unrecognized_token_value()
.expect("a hostile token is not a known phase");
assert_eq!(token.as_str(), hostile);
assert_eq!(phase.as_wire(), hostile, "the wire form stays verbatim");
for rendered in [token.to_string(), token.display_bounded(200)] {
for forbidden in ['\u{1b}', '\r', '\u{202e}'] {
assert!(
!rendered.contains(forbidden),
"{forbidden:?} survived into a display rendering: {rendered:?}"
);
}
assert!(
rendered.contains("synced"),
"escaping must stay legible, not redact"
);
}
}
#[test]
fn a_bounded_rendering_is_bounded_and_marked() {
let long = results::WalletSyncPhase::from("a".repeat(10_000).as_str());
let token = long.unrecognized_token_value().unwrap();
let rendered = token.display_bounded(32);
assert!(
rendered.trim_end_matches('…').len() <= 32,
"escaped content must respect the bound: {} bytes",
rendered.len()
);
assert!(rendered.ends_with('…'), "a truncated rendering must say so");
let short = results::WalletSyncPhase::from("a_newer_token");
let short_token = short.unrecognized_token_value().unwrap();
assert_eq!(short_token.display_bounded(64), "a_newer_token");
assert!(!short_token.display_bounded(64).ends_with('…'));
for (raw, max) in [
("", 0usize),
("\u{1b}\u{1b}\u{1b}", 0),
("\u{1b}\u{1b}\u{1b}", 5),
("\u{1b}\u{1b}\u{1b}", 6),
("\u{1f600}\u{1f600}", 1),
("\u{1f600}\u{1f600}", 4),
("\u{202e}abc", 3),
("\u{7f}\u{9b}", 32),
] {
let phase = results::WalletSyncPhase::from(raw);
let Some(token) = phase.unrecognized_token_value() else {
continue;
};
let rendered = token.display_bounded(max);
assert!(
rendered.trim_end_matches('…').len() <= max,
"{raw:?} at max={max} rendered {rendered:?}, over the bound"
);
for forbidden in ['\u{1b}', '\r', '\u{202e}', '\u{7f}', '\u{9b}'] {
assert!(
!rendered.contains(forbidden) && !token.to_string().contains(forbidden),
"{forbidden:?} survived rendering of {raw:?}"
);
}
}
}
#[test]
fn an_older_nodes_peer_counts_decode_with_the_known_count_unknown_not_zero() {
let legacy: results::PeerCountsResult = serde_json::from_value(json!({
"dig_peer_count": 6u32, "chia_peer_count": 3u32
}))
.expect("a payload from a node predating the known-peer count must decode, not be rejected");
assert_eq!(legacy.dig_peer_count, Some(6));
assert_eq!(legacy.chia_peer_count, Some(3));
assert_eq!(
legacy.known_dig_peer_count, None,
"a node that never reported a known-peer count has an UNKNOWN one; a zero here would \
claim it consulted an address book it was never asked about"
);
}
#[test]
fn knowing_of_peers_while_connected_to_none_is_expressible() {
let stranded: results::PeerCountsResult = serde_json::from_value(json!({
"dig_peer_count": 0u32, "chia_peer_count": 3u32, "known_dig_peer_count": 41u32
}))
.expect("the diagnostic vector must decode");
assert_eq!(stranded.dig_peer_count, Some(0), "connected to nobody");
assert_eq!(
stranded.known_dig_peer_count,
Some(41),
"while knowing of 41 — a reachability fault, not a discovery one"
);
}
#[test]
fn re_enrolling_a_key_succeeds_and_changes_nothing() {
let first = round_trip(&WalletWatchParams {
public_keys: vec![ENROL_KEY_A.into(), ENROL_KEY_B.into()],
})
.expect("watch must route");
assert_eq!((first.added, first.watched), (2, 2));
let again = round_trip(&WalletWatchParams {
public_keys: vec![ENROL_KEY_A.into()],
})
.expect("re-enrolment is a success, never an error");
assert_eq!(
(again.added, again.watched),
(0, 2),
"an already-enrolled key adds nothing and leaves the set the same size"
);
let novel = round_trip(&WalletWatchParams {
public_keys: vec!["c3".repeat(48)],
})
.expect("watch must route");
assert_eq!(
(novel.added, novel.watched),
(1, 3),
"the control: a genuinely new key still enrols, so `added: 0` above is idempotence and \
not a handler that never adds"
);
}
#[test]
fn the_prefixed_and_bare_spellings_are_the_same_key() {
let prefixed = round_trip(&WalletWatchParams {
public_keys: vec![format!("0x{ENROL_KEY_A}")],
})
.expect("a 0x-prefixed key is accepted");
assert_eq!((prefixed.added, prefixed.watched), (1, 1));
let bare = round_trip(&WalletWatchParams {
public_keys: vec![ENROL_KEY_A.into()],
})
.expect("watch must route");
assert_eq!(
(bare.added, bare.watched),
(0, 1),
"the prefix is stripped before the key is stored, so the two spellings are one key"
);
}
#[test]
fn a_single_malformed_key_refuses_the_whole_enrolment() {
let node = MockNode;
let req = JsonRpcRequest::new(
RequestId::Number(1),
ControlMethod::WalletWatch.name(),
json!({ "public_keys": [ENROL_KEY_A, SPENT_COIN] }),
);
let err = block_on(node.dispatch(req)).into_result().unwrap_err();
assert_eq!(err.code_enum(), Some(ControlErrorCode::InvalidParams));
assert!(
round_trip(&WalletWatchedParams {})
.expect("watched must route")
.public_keys
.is_empty(),
"the valid key submitted beside the malformed one must NOT have been enrolled"
);
}
#[test]
fn unwatch_removes_only_what_it_names() {
round_trip(&WalletWatchParams {
public_keys: vec![ENROL_KEY_A.into(), ENROL_KEY_B.into()],
})
.expect("watch must route");
let removed = round_trip(&WalletUnwatchParams {
public_keys: vec![ENROL_KEY_A.into(), "d4".repeat(48)],
})
.expect("unwatching a key that was never enrolled is a success");
assert_eq!(
(removed.removed, removed.watched),
(1, 1),
"one of the two named keys was enrolled; the never-enrolled one is not an error"
);
assert_eq!(
round_trip(&WalletWatchedParams {})
.expect("watched must route")
.public_keys,
vec![ENROL_KEY_B.to_owned()],
"the key that was not named survives -- unwatch is not a clear"
);
}
#[test]
fn an_untagged_subscription_decodes_as_a_capsule_and_a_tagged_one_is_honoured() {
let legacy: SubscribeParams =
serde_json::from_value(json!({ "store_id": STORE })).expect("an untagged row must decode");
assert_eq!(legacy.kind, SubscriptionKind::Capsule);
let tagged: SubscribeParams =
serde_json::from_value(json!({ "store_id": STORE, "kind": "profile" }))
.expect("a tagged row must decode");
assert_eq!(tagged.kind, SubscriptionKind::Profile);
}
#[test]
fn a_subscribe_result_from_an_older_node_decodes_as_a_capsule() {
let old: results::SubscribeResult =
serde_json::from_value(json!({ "subscribed": true, "added": true, "store_id": STORE }))
.expect("an older node's acknowledgement must decode");
assert_eq!(old.kind, SubscriptionKind::Capsule);
let new: results::SubscribeResult = serde_json::from_value(
json!({ "subscribed": true, "added": true, "store_id": STORE, "kind": "profile" }),
)
.expect("a tagged acknowledgement must decode");
assert_eq!(new.kind, SubscriptionKind::Profile);
}
#[test]
fn the_body_cap_is_four_mib_and_half_the_gossip_frame_ceiling() {
const WS_MAX_MESSAGE_BYTES: usize = 8 * 1024 * 1024;
assert_eq!(MAX_BODY_BYTES, 4 * 1024 * 1024);
assert_eq!(MAX_BODY_BYTES, WS_MAX_MESSAGE_BYTES / 2);
}
#[test]
fn the_dispatcher_routes_each_profile_method_to_its_own_handler() {
let put = round_trip(&ProfilePutBodyParams {
store_id: STORE.into(),
root: ROOT.into(),
body_b64: "QUJD".into(),
})
.expect("putBody must route");
assert!(put.stored);
assert_eq!(put.root, ROOT);
assert_eq!(put.body_bytes, 4, "the body itself must reach the handler");
let got = round_trip(&ProfileGetBodyParams {
store_id: STORE.into(),
root: ABSENT_COIN.into(),
})
.expect("getBody must route");
assert_eq!(
got.root, ABSENT_COIN,
"a read answers at the root it was ASKED for, never a newer one the node happens to hold"
);
assert_eq!(got.body_b64, None);
}
#[test]
fn adding_a_trusted_peer_returns_the_bypass_warning_as_quotable_text() {
let added = round_trip(&ChiaPeersAddParams {
ip: " 203.0.113.7 ".into(),
})
.expect("chiaPeers.add must route");
assert!(added.added);
assert_eq!(
added.ip, MOCK_TRUSTED_CHIA_PEER,
"the stored address is the CANONICAL form, so remove and list can match it"
);
assert!(added.corroboration_bypassed);
assert!(
!added.notice.trim().is_empty(),
"an empty notice discloses nothing"
);
assert!(
added.notice.to_lowercase().contains("corroboration"),
"the notice must name the cost it exists to disclose: {}",
added.notice
);
}
#[test]
fn removing_a_peer_the_node_never_had_is_not_reported_as_a_removal() {
let hit = round_trip(&ChiaPeersRemoveParams {
ip: MOCK_TRUSTED_CHIA_PEER.into(),
ban: false,
})
.expect("chiaPeers.remove must route");
assert_eq!(hit.outcome, results::ChiaPeerRemovalOutcome::Removed);
let miss = round_trip(&ChiaPeersRemoveParams {
ip: "198.51.100.4".into(),
ban: false,
})
.expect("chiaPeers.remove must route");
assert_eq!(
miss.outcome,
results::ChiaPeerRemovalOutcome::NoSuchPeer,
"a peer the node never held must not be reported as un-trusted"
);
assert_ne!(
hit.outcome, miss.outcome,
"the two cases must be distinguishable at the call site"
);
}
#[test]
fn a_peer_address_that_is_not_an_ip_literal_is_refused() {
for bad in ["[203.0.113.7]", "203.0.113.7:8444", "peer.example.com", ""] {
let err = round_trip(&ChiaPeersAddParams { ip: bad.into() })
.err()
.unwrap_or_else(|| panic!("{bad:?} must be refused"));
assert_eq!(err.code_enum(), Some(ControlErrorCode::InvalidParams));
}
}
#[test]
fn the_peer_list_separates_an_unobserved_peak_from_a_real_one() {
let listed = futures::executor::block_on(MockNode.chia_peers_list()).expect("list must answer");
let trusted = listed
.peers
.iter()
.find(|p| p.user_managed)
.expect("the mock holds one trusted peer");
let discovered = listed
.peers
.iter()
.find(|p| !p.user_managed)
.expect("and one discovered peer");
assert!(
trusted.peak_height.is_some(),
"a polled peer reports its claimed height"
);
assert_eq!(
discovered.peak_height, None,
"a peer nobody polled is UNOBSERVABLE, never height zero"
);
assert!(!trusted.banned && !discovered.banned);
}
#[test]
fn the_spec_method_table_is_not_interrupted_by_prose() {
const SPEC: &str = include_str!("../SPEC.md");
let mut rows = SPEC
.lines()
.skip_while(|l| !l.starts_with("| Method | Auth |"))
.skip(2) .take_while(|l| l.starts_with('|'));
let table: Vec<&str> = rows.by_ref().collect();
assert!(
table.iter().any(|l| l.contains("`control.chiaPeers.add`")),
"the trusted-peer rows belong in the table"
);
let last = table.last().expect("the catalog table has rows");
assert!(
last.contains("`pairing.poll`"),
"the table stops early — the open bootstrap rows have fallen out of it. Last row: {last}"
);
}
#[test]
fn adding_a_banned_peer_reports_that_no_bypass_was_granted() {
let trusted = round_trip(&ChiaPeersAddParams {
ip: MOCK_TRUSTED_CHIA_PEER.into(),
})
.expect("add must route");
let unbanned = round_trip(&ChiaPeersAddParams {
ip: MOCK_BANNED_CHIA_PEER.into(),
})
.expect("add must route");
assert!(trusted.added && unbanned.added, "both calls succeed");
assert!(trusted.corroboration_bypassed);
assert!(
!unbanned.corroboration_bypassed,
"the entry did not end up trusted, so the result must not claim the bypass"
);
assert!(
!unbanned.notice.trim().is_empty(),
"the person still needs to be told what actually happened"
);
}
const AUDIT_BASE_MS: u64 = 1_700_000_000_000;
const AUDIT_UNREADABLE: u32 = 2;
const AUDIT_FAILURE_REASON: &str = "no chain source could be reached";
fn audit_fixture() -> Vec<results::AutomatedSpend> {
fn spend(
id: &str,
initiated_ms: u64,
status: results::SpendOutcome,
chain: Option<results::SpendChainReference>,
) -> results::AutomatedSpend {
results::AutomatedSpend {
id: id.into(),
revision: 2,
kind: "mirror-coin".into(),
purpose: "renew the mirror advertising this store".into(),
authority: results::SpendAuthority {
principal: "node".into(),
grant: "auto_mirror_renewal".into(),
},
asset: results::SpendAsset::Xch,
amount_mojos: "9007199254740993".into(),
fee_mojos: "1000".into(),
store_id: Some(STORE.into()),
initiated_ms,
updated_ms: initiated_ms + 10,
status,
funding_coin_ids: vec![SPENT_COIN.into()],
chain_reference: chain,
}
}
vec![
spend(
"sp_confirmed",
AUDIT_BASE_MS + 400,
results::SpendOutcome::Confirmed {
height: 9_172_077,
coin_id: CHILD_COINS[0].into(),
},
Some(results::SpendChainReference {
coin_id: CHILD_COINS[0].into(),
confirmed: true,
}),
),
spend(
"sp_broadcast",
AUDIT_BASE_MS + 300,
results::SpendOutcome::Failed {
stage: results::SpendFailureStage::Broadcast,
reason: AUDIT_FAILURE_REASON.into(),
},
Some(results::SpendChainReference {
coin_id: CHILD_COINS[1].into(),
confirmed: false,
}),
),
spend(
"sp_signing",
AUDIT_BASE_MS + 200,
results::SpendOutcome::Failed {
stage: results::SpendFailureStage::Signing,
reason: AUDIT_FAILURE_REASON.into(),
},
None,
),
spend(
"sp_unresolved",
AUDIT_BASE_MS + 100,
results::SpendOutcome::Unresolved {
reason: "the node restarted while the spend was in flight".into(),
},
Some(results::SpendChainReference {
coin_id: CHILD_COINS[2].into(),
confirmed: false,
}),
),
]
}
fn audit_page(params: &SpendsListParams) -> results::SpendsListResult {
let mut rows = audit_fixture();
if let Some(status) = params.status.as_deref() {
rows.retain(|r| r.status.token() == status);
}
if let Some(kind) = params.kind.as_deref() {
rows.retain(|r| r.kind == kind);
}
if let Some(since) = params.since_ms {
rows.retain(|r| r.initiated_ms >= since);
}
if let Some(until) = params.until_ms {
rows.retain(|r| r.initiated_ms < until);
}
if let Some(after) = params.after_id.as_deref() {
match rows.iter().position(|r| r.id == after) {
Some(i) => rows.drain(..=i).for_each(drop),
None => rows.clear(),
}
}
let limit = params.effective_limit() as usize;
let complete = rows.len() <= limit;
rows.truncate(limit);
results::SpendsListResult {
cursor: rows.last().map(|r| r.id.clone()),
spends: rows,
complete,
unreadable_lines: AUDIT_UNREADABLE,
}
}
#[test]
fn a_broadcast_failure_is_not_the_same_answer_as_a_signing_failure() {
let node = MockNode;
let page = block_on(node.spends_list(SpendsListParams::default())).unwrap();
let broadcast = page.spends.iter().find(|s| s.id == "sp_broadcast").unwrap();
let signing = page.spends.iter().find(|s| s.id == "sp_signing").unwrap();
let confirmed = page.spends.iter().find(|s| s.id == "sp_confirmed").unwrap();
assert_eq!(confirmed.status.token(), "confirmed");
assert_eq!(broadcast.status.token(), "failed");
assert_eq!(signing.status.token(), "failed");
assert_ne!(
serde_json::to_value(&broadcast.status).unwrap(),
serde_json::to_value(&signing.status).unwrap(),
"a flattened `failed` makes these two identical, which is the money-lie this shape exists \
to prevent"
);
assert!(broadcast.status.outcome_is_unknown());
assert!(!signing.status.outcome_is_unknown());
assert!(!confirmed.status.outcome_is_unknown());
}
#[test]
fn an_unresolved_spend_is_not_returned_as_a_failure() {
let node = MockNode;
let failed = block_on(node.spends_list(SpendsListParams {
status: Some("failed".into()),
..SpendsListParams::default()
}))
.unwrap();
let ids: Vec<&str> = failed.spends.iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids, vec!["sp_broadcast", "sp_signing"]);
let unresolved = block_on(node.spends_list(SpendsListParams {
status: Some("unresolved".into()),
..SpendsListParams::default()
}))
.unwrap();
assert_eq!(unresolved.spends.len(), 1);
assert!(unresolved.spends[0].status.outcome_is_unknown());
assert!(matches!(
unresolved.spends[0].status,
results::SpendOutcome::Unresolved { .. }
));
}
#[test]
fn a_truncated_spend_page_and_a_final_one_are_told_apart_only_by_complete() {
let node = MockNode;
let first = block_on(node.spends_list(SpendsListParams {
limit: Some(2),
..SpendsListParams::default()
}))
.unwrap();
assert_eq!(first.spends.len(), 2);
assert!(
!first.complete,
"more rows were withheld, and it must say so"
);
assert_eq!(first.cursor.as_deref(), Some("sp_broadcast"));
let second = block_on(node.spends_list(SpendsListParams {
limit: Some(2),
after_id: first.cursor.clone(),
..SpendsListParams::default()
}))
.unwrap();
assert_eq!(second.spends.len(), first.spends.len());
assert!(second.complete, "the walk is finished and must say so");
assert_eq!(second.cursor.as_deref(), Some("sp_unresolved"));
let walked: Vec<&str> = first
.spends
.iter()
.chain(second.spends.iter())
.map(|s| s.id.as_str())
.collect();
assert_eq!(
walked,
vec![
"sp_confirmed",
"sp_broadcast",
"sp_signing",
"sp_unresolved"
]
);
}
#[test]
fn unreadable_entries_are_reported_on_every_page() {
let node = MockNode;
let whole = block_on(node.spends_list(SpendsListParams::default())).unwrap();
assert_eq!(whole.unreadable_lines, AUDIT_UNREADABLE);
let page = block_on(node.spends_list(SpendsListParams {
limit: Some(1),
..SpendsListParams::default()
}))
.unwrap();
assert_eq!(page.unreadable_lines, AUDIT_UNREADABLE);
}
#[test]
fn the_page_bound_is_refused_from_above_and_accepted_at_the_bound() {
let at_bound = SpendsListParams {
limit: Some(SPENDS_LIST_MAX_LIMIT),
..SpendsListParams::default()
};
assert!(at_bound.validated().is_ok(), "the cap itself must be legal");
for bad in [0, SPENDS_LIST_MAX_LIMIT + 1] {
let err = SpendsListParams {
limit: Some(bad),
..SpendsListParams::default()
}
.validated()
.expect_err("an out-of-range page size must be refused");
assert_eq!(err.code_enum(), Some(ControlErrorCode::InvalidParams));
}
let over = serde_json::to_value(SPENDS_LIST_MAX_LIMIT + 1).unwrap();
assert!(serde_json::from_value::<SpendsListParams>(json!({"limit": over})).is_err());
assert!(serde_json::from_value::<SpendsListParams>(json!({"limit": 0})).is_err());
let defaulted: SpendsListParams = serde_json::from_value(json!({})).unwrap();
assert_eq!(defaulted.effective_limit(), SPENDS_LIST_DEFAULT_LIMIT);
}
#[test]
fn the_audit_read_is_gated_because_the_caller_names_nothing() {
assert!(ControlMethod::SpendsList.requires_auth());
assert!(!ControlMethod::SpendsList.is_open_read());
assert!(!ControlMethod::SpendsList.requires_master_token());
assert_eq!(ControlMethod::SpendsList.name(), "control.spends.list");
}
#[test]
fn spends_list_wire_vectors_are_pinned() {
assert_request(
&SpendsListParams {
status: Some("failed".into()),
limit: Some(2),
..SpendsListParams::default()
},
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "control.spends.list",
"params": {"status": "failed", "limit": 2},
}),
);
assert_result_round_trips::<results::SpendsListResult>(json!({
"spends": [],
"complete": true,
"cursor": null,
"unreadable_lines": 0,
}));
assert_result_round_trips::<results::SpendsListResult>(json!({
"spends": [{
"id": "sp_broadcast",
"revision": 2,
"kind": "mirror-coin",
"purpose": "renew the mirror advertising this store",
"authority": {"principal": "node", "grant": "auto_mirror_renewal"},
"asset": {"asset": "xch"},
"amount_mojos": "9007199254740993",
"fee_mojos": "1000",
"store_id": STORE,
"initiated_ms": AUDIT_BASE_MS + 300,
"updated_ms": AUDIT_BASE_MS + 310,
"status": {"state": "failed", "stage": "broadcast", "reason": "mempool rejected the bundle"},
"funding_coin_ids": [SPENT_COIN],
"chain_reference": {"coin_id": CHILD_COINS[1], "confirmed": false},
}],
"complete": false,
"cursor": "sp_broadcast",
"unreadable_lines": 2,
}));
}
#[test]
fn an_absent_cursor_or_chain_reference_key_is_a_decode_error() {
assert!(serde_json::from_value::<results::SpendsListResult>(json!({
"spends": [],
"complete": true,
"unreadable_lines": 0,
}))
.is_err());
assert!(serde_json::from_value::<results::AutomatedSpend>(json!({
"id": "sp_signing",
"revision": 1,
"kind": "mirror-coin",
"purpose": "p",
"authority": {"principal": "node", "grant": "g"},
"asset": {"asset": "dig"},
"amount_mojos": "1",
"fee_mojos": "0",
"store_id": null,
"initiated_ms": AUDIT_BASE_MS,
"updated_ms": AUDIT_BASE_MS,
"status": {"state": "failed", "stage": "signing", "reason": "insufficient funds"},
"funding_coin_ids": [],
}))
.is_err());
}
#[test]
fn every_catalogued_error_code_is_a_row_of_an_error_code_table() {
fn table_rows(text: &str) -> Vec<&str> {
fn flush<'a>(block: &mut Vec<&'a str>, rows: &mut Vec<&'a str>) {
let is_table = block
.iter()
.any(|l| l.trim_start_matches('|').trim_start().starts_with("---"));
if is_table {
rows.extend(block.iter().copied());
}
block.clear();
}
let mut rows: Vec<&str> = Vec::new();
let mut block: Vec<&str> = Vec::new();
for line in text.lines() {
if line.trim_start().starts_with('|') {
block.push(line.trim());
} else {
flush(&mut block, &mut rows);
}
}
flush(&mut block, &mut rows);
rows
}
let spec_rows = table_rows(include_str!("../SPEC.md"));
let readme_rows = table_rows(include_str!("../README.md"));
for &code in ControlErrorCode::ALL {
let row = format!("| `{}` | `{}` |", code.code(), code.name());
assert!(
spec_rows.iter().any(|l| l.starts_with(&row)),
"SPEC.md has no error-code TABLE ROW starting `{row}` -- either the code is undeclared, \
it carries the wrong symbol, or a blank line has split its row out of the table (which \
renders it as a paragraph, and SPEC.md's own MUST then forbids the code)"
);
let number_cell = format!("| `{}` |", code.code());
if let Some(listed) = readme_rows.iter().find(|l| l.starts_with(&number_cell)) {
assert!(
listed.starts_with(&row),
"README.md declares `{}` with the wrong symbol: {listed}",
code.code()
);
}
}
}
#[test]
fn the_collateral_methods_are_named_categorised_and_gated() {
use crate::method::{Category, Routing};
assert_eq!(
ControlMethod::CollateralRequirement.name(),
"control.collateral.requirement"
);
assert_eq!(
ControlMethod::CollateralMarginGet.name(),
"control.collateral.margin.get"
);
assert_eq!(
ControlMethod::CollateralMarginSet.name(),
"control.collateral.margin.set"
);
for m in [
ControlMethod::CollateralRequirement,
ControlMethod::CollateralMarginGet,
ControlMethod::CollateralMarginSet,
] {
assert_eq!(m.category(), Category::Collateral, "{}", m.name());
assert_eq!(m.routing(), Routing::Owned, "{}", m.name());
assert!(m.requires_auth(), "{} must be token-gated", m.name());
assert!(!m.is_open_read(), "{} must not be an open read", m.name());
assert!(
!m.requires_master_token(),
"{} grants no authority outliving its token, so it stays on the ordinary tier",
m.name()
);
assert!(
ControlMethod::ALL.contains(&m),
"{} missing from ALL",
m.name()
);
}
}
#[test]
fn the_margin_is_basis_points_under_the_margin_bp_key() {
let set = CollateralMarginSetParams { margin_bp: 1 };
assert_eq!(
serde_json::to_value(set).unwrap(),
json!({ "margin_bp": 1 }),
"a tight 1bp margin must stay 1 bp; converting to percent would render it as 0"
);
let read: results::CollateralMarginResult =
serde_json::from_value(json!({ "margin_bp": 1 })).unwrap();
assert_eq!(read.margin_bp, 1);
assert_eq!(DEFAULT_SAFETY_MARGIN_BP, 100);
}
#[test]
fn the_margin_ceiling_is_pinned_from_both_sides() {
let at_bound = CollateralMarginSetParams {
margin_bp: MAX_SAFETY_MARGIN_BP,
};
assert!(
at_bound.validated().is_ok(),
"the ceiling itself must be a legal margin"
);
let over = CollateralMarginSetParams {
margin_bp: MAX_SAFETY_MARGIN_BP + 1,
};
let err = over
.validated()
.expect_err("one over the ceiling must be refused");
assert_eq!(err.code, ControlErrorCode::InvalidParams.code());
}
#[test]
fn an_unknown_requirement_carries_a_reason_and_no_number() {
let unknown: results::CollateralRequirementResult = serde_json::from_value(json!({
"state": "unknown",
"reason": "not_censused",
}))
.unwrap();
match unknown {
results::CollateralRequirementResult::Unknown { reason } => {
assert_eq!(reason, results::CollateralUnknownReason::NotCensused);
}
results::CollateralRequirementResult::Known { .. } => {
panic!("an unknown requirement decoded as a known figure")
}
}
let tokens: std::collections::BTreeSet<&str> = results::CollateralUnknownReason::ALL
.iter()
.map(|r| r.as_wire())
.collect();
assert_eq!(tokens.len(), results::CollateralUnknownReason::ALL.len());
}
#[test]
fn every_collateral_unknown_reason_is_listed_in_all() {
use results::CollateralUnknownReason as R;
const fn index(reason: R) -> usize {
match reason {
R::NotCensused => 0,
R::BehindFinalityDepth => 1,
R::RecordUnreadable => 2,
R::NoChainSource => 3,
R::BalanceUnreadable => 4,
}
}
const VARIANT_COUNT: usize = 5;
assert_eq!(
R::ALL.len(),
VARIANT_COUNT,
"ALL has drifted from the variant set"
);
for i in 0..VARIANT_COUNT {
assert!(
R::ALL.iter().any(|r| index(*r) == i),
"variant with index {i} is missing from ALL"
);
}
}
#[test]
fn the_operator_address_is_token_gated_and_answered_by_this_node() {
assert!(
ControlMethod::WalletOperatorAddress.requires_auth(),
"a stranger must not be able to map this node to a chain identity"
);
assert!(!ControlMethod::WalletOperatorAddress.is_open_read());
assert_eq!(
ControlMethod::WalletOperatorAddress.routing(),
crate::method::Routing::Owned,
"forwarding this upstream would answer with ANOTHER machine's wallet address"
);
assert!(ControlMethod::WalletPeak.is_open_read());
assert_eq!(
ControlMethod::WalletPeak.routing(),
crate::method::Routing::Delegated
);
assert_eq!(
ControlMethod::WalletOperatorAddress.name(),
"control.wallet.operatorAddress"
);
assert_eq!(
ControlMethod::from_name("control.wallet.operatorAddress"),
Some(ControlMethod::WalletOperatorAddress),
"a variant missing from ControlMethod::ALL is unreachable by name"
);
}
#[test]
fn the_operator_address_result_carries_a_destination_and_no_spending_material() {
let known = results::WalletOperatorAddressResult::Known {
address: "xch1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsjqfwvy".into(),
puzzle_hash: "00".repeat(32),
};
assert_eq!(
serde_json::to_value(&known).unwrap(),
serde_json::json!({
"state": "known",
"address": "xch1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsjqfwvy",
"puzzle_hash": "00".repeat(32),
}),
"exactly two fields: where money goes, and the same place as a puzzle hash"
);
for (reason, token) in [
(
results::WalletOperatorAddressUnavailableReason::NotInitialized,
"not_initialized",
),
(
results::WalletOperatorAddressUnavailableReason::Unreadable,
"unreadable",
),
] {
let unavailable = results::WalletOperatorAddressResult::Unavailable { reason };
let wire = serde_json::to_value(&unavailable).unwrap();
assert_eq!(
wire,
serde_json::json!({ "state": "unavailable", "reason": token })
);
assert!(
wire.get("address").is_none(),
"an unavailable answer must not carry an address field at all -- a blank or \
placeholder destination is a money statement"
);
assert_result_round_trips::<results::WalletOperatorAddressResult>(wire);
}
}
#[test]
fn an_unadvertised_bond_is_its_own_state_and_not_disabled() {
let unadvertised = serde_json::to_value(results::MirrorBondState::Unadvertised).unwrap();
let disabled = serde_json::to_value(results::MirrorBondState::Disabled).unwrap();
assert_eq!(
unadvertised,
serde_json::json!({ "bond_state": "unadvertised" }),
"the token is the contract, and it is a bare tag with no payload"
);
assert_ne!(
unadvertised, disabled,
"a node whose switch is ON must not be reported under the switch's own token"
);
let parsed: results::MirrorBondState = serde_json::from_value(unadvertised).unwrap();
assert_eq!(parsed, results::MirrorBondState::Unadvertised);
}
#[test]
fn a_balance_unreadable_bond_is_deferred_not_unfunded() {
let state = results::MirrorBondState::Deferred {
reason: results::CollateralUnknownReason::BalanceUnreadable,
};
assert_eq!(
serde_json::to_value(&state).unwrap(),
json!({"bond_state": "deferred", "reason": "balance_unreadable"}),
"the wallet-unreadable bond's wire bytes are pinned"
);
let round: results::MirrorBondState =
serde_json::from_value(json!({"bond_state": "deferred", "reason": "balance_unreadable"}))
.unwrap();
assert_eq!(round, state);
assert!(
serde_json::from_value::<results::MirrorBondState>(json!({"bond_state": "deferred"}))
.is_err(),
"a deferred bond without a reason must fail to decode"
);
assert_eq!(
results::CollateralUnknownReason::BalanceUnreadable.as_wire(),
"balance_unreadable"
);
assert!(
results::CollateralUnknownReason::ALL
.iter()
.filter(|r| r.as_wire() == "balance_unreadable")
.count()
== 1,
"exactly one variant owns the balance_unreadable token"
);
}
#[test]
fn a_known_requirement_must_declare_its_protocol_version() {
let complete = json!({
"state": "known",
"epoch": 7,
"protocol_version": 1,
"required_per_store_dig_base_units": 1_036,
"stores": 4_200,
"owners": 310,
"multiplier_micros": 1_050_000,
"handicap_dig_base_units": 2_760,
});
let known: results::CollateralRequirementResult =
serde_json::from_value(complete.clone()).unwrap();
match known {
results::CollateralRequirementResult::Known {
epoch,
protocol_version,
required_per_store_dig_base_units,
stores,
owners,
multiplier_micros,
handicap_dig_base_units,
} => {
assert_eq!(epoch, 7);
assert_eq!(protocol_version, 1);
assert_eq!(required_per_store_dig_base_units, 1_036);
assert_eq!(stores, 4_200);
assert_eq!(owners, 310);
assert_eq!(multiplier_micros, 1_050_000);
assert_eq!(handicap_dig_base_units, 2_760);
}
results::CollateralRequirementResult::Unknown { .. } => panic!("known decoded as unknown"),
}
let mut missing = complete.as_object().unwrap().clone();
missing.remove("protocol_version");
assert!(
serde_json::from_value::<results::CollateralRequirementResult>(Value::Object(missing))
.is_err(),
"a requirement without its protocol version must be REFUSED, not defaulted"
);
}
#[test]
fn setting_the_margin_persists_it_and_getting_it_back_agrees() {
let set = round_trip(&CollateralMarginSetParams { margin_bp: 1 })
.expect("setting a legal margin must succeed");
assert_eq!(set.margin_bp, 1);
let got =
round_trip(&CollateralMarginGetParams {}).expect("reading the margin back must succeed");
assert_eq!(
got.margin_bp, 1,
"the margin read back must be the one set, not the default and not an echo"
);
}
#[test]
fn the_published_margin_bounds_match_the_declared_constants() {
let spec = include_str!("../SPEC.md");
for (label, name, value) in [
("ceiling", "MAX_SAFETY_MARGIN_BP", MAX_SAFETY_MARGIN_BP),
(
"default",
"DEFAULT_SAFETY_MARGIN_BP",
DEFAULT_SAFETY_MARGIN_BP,
),
] {
let published = published_bp_figures(spec, name);
assert!(
!published.is_empty(),
"SPEC.md never states the margin {label} as `{name}` (<n> bp, …), so its normative prose \
and the constant can drift apart unnoticed"
);
for figure in published {
assert_eq!(
figure, value,
"SPEC.md publishes the margin {label} as {figure} bp while `{name}` is {value}; a \
reimplementer building against the document would enforce a different money-path \
bound than this crate does"
);
}
}
}
fn published_bp_figures(spec: &str, name: &str) -> Vec<u64> {
spec.match_indices(name)
.filter_map(|(at, _)| {
let tail = spec[at + name.len()..].trim_start_matches(['`', ' ']);
let digits = tail.strip_prefix('(')?;
let end = digits.find(|c: char| !c.is_ascii_digit())?;
if !digits[end..].starts_with(" bp") {
return None;
}
digits[..end].parse().ok()
})
.collect()
}
#[test]
fn the_buffer_method_is_in_the_catalog_categorised_and_gated() {
let m = ControlMethod::from_name("control.collateral.buffer")
.expect("control.collateral.buffer must be reachable through ControlMethod::ALL");
assert_eq!(m.category(), crate::method::Category::Collateral);
assert_eq!(m.routing(), crate::method::Routing::Owned);
assert!(m.requires_auth(), "the buffer read is token-gated");
assert!(!m.is_open_read());
assert!(!m.requires_master_token(), "a read grants nothing lasting");
}
#[test]
fn a_buffer_without_its_horizon_does_not_decode() {
let full = json!({
"state": "known",
"epoch": 7,
"protocol_version": 1,
"funding_state": "below_recommended_buffer",
"recommended_buffer_dig_base_units": 32_400,
"spendable_dig_base_units": 40_000,
"pairs_served_by_this_node": 12,
"required_per_store_dig_base_units": 1_036,
"margin_bp": 100,
"overlap_dig_base_units": 3_108,
"escalation_headroom_dig_base_units": 7_468,
"horizon_epochs": 4,
"escalation_ceiling_micros": 1_601_806
});
serde_json::from_value::<results::CollateralBufferResult>(full.clone())
.expect("the intact payload must decode -- otherwise the removals below prove nothing");
for required in ["horizon_epochs", "escalation_ceiling_micros"] {
let mut stripped = full.as_object().unwrap().clone();
stripped.remove(required);
assert!(
serde_json::from_value::<results::CollateralBufferResult>(Value::Object(stripped))
.is_err(),
"{required} must be REQUIRED: a buffer read without it cannot be checked by anyone"
);
}
}
#[test]
fn an_unknown_buffer_carries_a_reason_and_never_a_number() {
for &reason in results::CollateralBufferUnknownReason::ALL {
let json =
serde_json::to_value(results::CollateralBufferResult::Unknown { reason }).unwrap();
let obj = json.as_object().expect("the unknown answer is an object");
assert_eq!(obj["state"], "unknown");
assert_eq!(obj["reason"], reason.as_wire());
assert_eq!(
obj.len(),
2,
"an unknown answer carries the tag and the reason and nothing else: {json}"
);
for (key, value) in obj {
assert!(
!value.is_number() && !value.is_null(),
"`{key}` puts a number-shaped value on an unknown answer: {json}"
);
}
}
let tokens: std::collections::BTreeSet<&str> = results::CollateralBufferUnknownReason::ALL
.iter()
.map(|r| r.as_wire())
.collect();
assert_eq!(
tokens.len(),
results::CollateralBufferUnknownReason::ALL.len(),
"two reasons sharing a wire token make one of them unreportable"
);
}
#[test]
fn exactly_two_funding_states_mean_an_epoch_is_uncovered() {
let tokens: std::collections::BTreeSet<&str> = results::CollateralFundingState::ALL
.iter()
.map(|s| s.as_wire())
.collect();
assert_eq!(tokens.len(), results::CollateralFundingState::ALL.len());
assert_eq!(
tokens,
[
"short_now",
"dangerously_low",
"below_recommended_buffer",
"funded"
]
.into_iter()
.collect()
);
let shortfalls: std::collections::BTreeSet<&str> = results::CollateralFundingState::ALL
.iter()
.filter(|s| s.is_shortfall())
.map(|s| s.as_wire())
.collect();
assert_eq!(
shortfalls,
["short_now", "dangerously_low"].into_iter().collect(),
"below_recommended_buffer is a READOUT: nothing is uncovered there"
);
for &state in results::CollateralFundingState::ALL {
let back: results::CollateralFundingState =
serde_json::from_value(json!(state.as_wire())).unwrap();
assert_eq!(back, state);
}
}
#[test]
fn the_buffer_read_returns_this_nodes_served_set_not_the_census_count() {
let census_stores = match round_trip(&CollateralRequirementParams {}).unwrap() {
results::CollateralRequirementResult::Known { stores, .. } => stores,
results::CollateralRequirementResult::Unknown { .. } => panic!("mock answers known"),
};
match round_trip(&CollateralBufferParams {}).unwrap() {
results::CollateralBufferResult::Known {
funding_state,
pairs_served_by_this_node,
horizon_epochs,
recommended_buffer_dig_base_units,
..
} => {
assert_ne!(
pairs_served_by_this_node, census_stores,
"the served set must not be the network-wide advertisement count"
);
assert_eq!(pairs_served_by_this_node, 12);
assert_eq!(
funding_state,
results::CollateralFundingState::DangerouslyLow
);
assert!(funding_state.is_shortfall());
assert_eq!(horizon_epochs, 4);
assert!(recommended_buffer_dig_base_units > 0);
}
results::CollateralBufferResult::Unknown { reason } => {
panic!("the mock states a buffer, got unknown: {reason:?}")
}
}
}
const BOND_STORE_B: &str = "a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2";
const BOND_ROOT_B: &str = "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2";
const OPERATOR_ADDRESS: &str =
"xch1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsjqfwvy";
const BOND_COIN_A: &str = "d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1";
const BOND_COIN_B: &str = "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2";
#[test]
fn the_bond_state_method_is_named_categorised_and_gated() {
assert_eq!(
ControlMethod::MirrorBondStates.name(),
"control.mirror.bondStates"
);
assert_eq!(
ControlMethod::from_name("control.mirror.bondStates"),
Some(ControlMethod::MirrorBondStates)
);
assert_eq!(
ControlMethod::MirrorBondStates.category(),
crate::method::Category::Collateral
);
assert!(ControlMethod::MirrorBondStates.requires_auth());
assert!(!ControlMethod::MirrorBondStates.is_open_read());
assert!(!ControlMethod::MirrorBondStates.requires_master_token());
assert!(ControlMethod::ALL.contains(&ControlMethod::MirrorBondStates));
}
#[test]
fn golden_bond_state_vectors_pin_every_state() {
let rows = json!([
{
"store_id": STORE, "root": ROOT, "bond_state": "bonded",
"coin_id": BOND_COIN_A, "epoch": 7u64, "amount_dig_base_units": 1_047u64
},
{
"store_id": STORE, "root": BOND_ROOT_B, "bond_state": "unfunded",
"short_dig_base_units": 1_047u64
},
{"store_id": BOND_STORE_B, "root": ROOT, "bond_state": "pending"},
{
"store_id": BOND_STORE_B, "root": BOND_ROOT_B, "bond_state": "deferred",
"reason": "not_censused"
},
]);
assert_result_round_trips::<results::MirrorBondStatesResult>(json!({
"state": "known",
"entries": rows,
"complete": false,
"cursor": {"store_id": BOND_STORE_B, "root": BOND_ROOT_B},
"locked_dig_base_units": 3_094u64,
"epoch": 7u64,
}));
assert_result_round_trips::<results::MirrorBondStatesResult>(json!({
"state": "known",
"entries": [
{"store_id": STORE, "root": ROOT, "bond_state": "withheld"},
{"store_id": STORE, "root": BOND_ROOT_B, "bond_state": "disabled"},
{
"store_id": BOND_STORE_B, "root": ROOT, "bond_state": "reclaiming",
"coin_id": BOND_COIN_B, "epoch": 6u64, "amount_dig_base_units": 2_047u64
},
],
"complete": true,
"cursor": {"store_id": BOND_STORE_B, "root": ROOT},
"locked_dig_base_units": 2_047u64,
"epoch": 7u64,
}));
for reason in results::MirrorBondStatesUnknownReason::ALL {
assert_result_round_trips::<results::MirrorBondStatesResult>(json!({
"state": "unknown",
"reason": reason.as_wire(),
}));
}
}
#[test]
fn the_bond_surface_wire_tokens_are_unique() {
let reasons: Vec<&str> = results::MirrorBondStatesUnknownReason::ALL
.iter()
.map(|r| r.as_wire())
.collect();
let mut sorted = reasons.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), reasons.len(), "unknown-reason tokens collide");
let states = [
results::MirrorBondState::Bonded {
coin_id: BOND_COIN_A.into(),
epoch: 7,
amount_dig_base_units: 1_047,
},
results::MirrorBondState::Pending,
results::MirrorBondState::Unfunded {
short_dig_base_units: 1_047,
},
results::MirrorBondState::Deferred {
reason: results::CollateralUnknownReason::NoChainSource,
},
results::MirrorBondState::Withheld,
results::MirrorBondState::Disabled,
results::MirrorBondState::Unadvertised,
results::MirrorBondState::Reclaiming {
coin_id: BOND_COIN_B.into(),
epoch: 6,
amount_dig_base_units: 2_047,
},
];
let mut tokens: Vec<String> = states
.iter()
.map(|s| {
serde_json::to_value(s).unwrap()["bond_state"]
.as_str()
.expect("every state carries a bond_state token")
.to_owned()
})
.collect();
assert_eq!(tokens.len(), 8, "all eight states must be represented");
tokens.sort();
tokens.dedup();
assert_eq!(tokens.len(), 8, "bond-state tokens collide");
}
#[test]
fn an_absent_paging_key_never_becomes_a_definite_answer() {
let complete_known = json!({
"state": "known",
"entries": [],
"complete": true,
"cursor": null,
"locked_dig_base_units": 0u64,
"epoch": 7u64,
});
assert_result_round_trips::<results::MirrorBondStatesResult>(complete_known.clone());
for missing in ["cursor", "complete"] {
let mut wire = complete_known.clone();
wire.as_object_mut().unwrap().remove(missing);
assert!(
serde_json::from_value::<results::MirrorBondStatesResult>(wire).is_err(),
"an absent `{missing}` must be a decode failure, never a default"
);
}
}
#[test]
fn the_locked_total_spans_pages_and_is_never_the_page_sum() {
let wire = json!({
"state": "known",
"entries": [{
"store_id": STORE, "root": ROOT, "bond_state": "bonded",
"coin_id": BOND_COIN_A, "epoch": 7u64, "amount_dig_base_units": 1_047u64
}],
"complete": false,
"cursor": {"store_id": STORE, "root": ROOT},
"locked_dig_base_units": 5_000u64,
"epoch": 7u64,
});
assert_result_round_trips::<results::MirrorBondStatesResult>(wire.clone());
let parsed: results::MirrorBondStatesResult = serde_json::from_value(wire).unwrap();
let results::MirrorBondStatesResult::Known {
entries,
complete,
locked_dig_base_units,
..
} = parsed
else {
panic!("the vector is a known answer");
};
assert!(!complete, "the fixture must be a TRUNCATED page");
let page_sum: u64 = entries
.iter()
.map(|e| match &e.state {
results::MirrorBondState::Bonded {
amount_dig_base_units,
..
}
| results::MirrorBondState::Reclaiming {
amount_dig_base_units,
..
} => *amount_dig_base_units,
_ => 0,
})
.sum();
assert!(
page_sum < locked_dig_base_units,
"the page must under-count the locked total, or this proves nothing"
);
}
#[test]
fn the_bond_page_bound_is_enforced_from_both_sides() {
let at_bound = MirrorBondStatesParams {
limit: Some(MIRROR_BOND_STATES_MAX_LIMIT),
..MirrorBondStatesParams::default()
};
assert!(
at_bound.clone().validated().is_ok(),
"the cap itself must be accepted"
);
assert_eq!(at_bound.effective_limit(), MIRROR_BOND_STATES_MAX_LIMIT);
for bad in [0, MIRROR_BOND_STATES_MAX_LIMIT + 1] {
let err = MirrorBondStatesParams {
limit: Some(bad),
..MirrorBondStatesParams::default()
}
.validated()
.expect_err("an out-of-range page size must be refused");
assert_eq!(err.code_enum(), Some(ControlErrorCode::InvalidParams));
let over = serde_json::to_value(bad).unwrap();
assert!(
serde_json::from_value::<MirrorBondStatesParams>(json!({"limit": over})).is_err(),
"the refusal must be enforced on the way IN, so a node cannot forget it"
);
}
let defaulted: MirrorBondStatesParams = serde_json::from_value(json!({})).unwrap();
assert_eq!(
defaulted.effective_limit(),
MIRROR_BOND_STATES_DEFAULT_LIMIT
);
assert_eq!(defaulted.after, None);
}
#[test]
fn bond_states_request_wire_vector_is_pinned() {
assert_request(
&MirrorBondStatesParams::default(),
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "control.mirror.bondStates",
"params": {},
}),
);
assert_request(
&MirrorBondStatesParams {
after: Some(results::MirrorBondKey {
store_id: STORE.into(),
root: ROOT.into(),
}),
limit: Some(2),
},
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "control.mirror.bondStates",
"params": {"after": {"store_id": STORE, "root": ROOT}, "limit": 2},
}),
);
}
#[test]
fn a_provenance_blind_producer_can_say_so_instead_of_shipping_a_short_page() {
let truthful = json!({
"state": "known",
"entries": [
{"store_id": STORE, "root": ROOT, "bond_state": "bonded",
"coin_id": BOND_COIN_A, "epoch": 7u64, "amount_dig_base_units": 1_047u64},
{"store_id": STORE, "root": BOND_ROOT_B, "bond_state": "withheld"},
],
"complete": true,
"cursor": {"store_id": STORE, "root": BOND_ROOT_B},
"locked_dig_base_units": 1_047u64,
"epoch": 7u64,
});
assert_result_round_trips::<results::MirrorBondStatesResult>(truthful);
let sanctioned = json!({"state": "unknown", "reason": "provenance_unknown"});
assert_result_round_trips::<results::MirrorBondStatesResult>(sanctioned.clone());
let parsed: results::MirrorBondStatesResult = serde_json::from_value(sanctioned).unwrap();
assert_eq!(
parsed,
results::MirrorBondStatesResult::Unknown {
reason: results::MirrorBondStatesUnknownReason::ProvenanceUnknown,
},
"a provenance-blind node's answer must be the WHOLE-call refusal, never a page"
);
assert_ne!(
results::MirrorBondStatesUnknownReason::ProvenanceUnknown.as_wire(),
results::MirrorBondStatesUnknownReason::ServedSetUnknown.as_wire()
);
assert!(
results::MirrorBondStatesUnknownReason::ALL
.contains(&results::MirrorBondStatesUnknownReason::ProvenanceUnknown),
"a reason a producer must be able to emit has to be enumerable by a renderer"
);
let encoded = serde_json::to_value(results::MirrorBondStatesResult::Unknown {
reason: results::MirrorBondStatesUnknownReason::ProvenanceUnknown,
})
.unwrap();
assert_eq!(
encoded["reason"],
json!(results::MirrorBondStatesUnknownReason::ProvenanceUnknown.as_wire())
);
let short_page = json!({
"state": "known",
"entries": [
{"store_id": STORE, "root": ROOT, "bond_state": "bonded",
"coin_id": BOND_COIN_A, "epoch": 7u64, "amount_dig_base_units": 1_047u64},
],
"complete": true,
"cursor": {"store_id": STORE, "root": ROOT},
"locked_dig_base_units": 1_047u64,
"epoch": 7u64,
});
let short: results::MirrorBondStatesResult = serde_json::from_value(short_page).unwrap();
assert_ne!(short, parsed);
let results::MirrorBondStatesResult::Known {
entries, complete, ..
} = &short
else {
panic!("the forbidden alternative is a `known` page by construction")
};
assert!(
*complete && entries.len() == 1,
"the failure this reason removes is a complete-looking page missing its withheld rows"
);
}
#[test]
fn a_malformed_bond_cursor_is_refused_rather_than_restarting_the_walk() {
let key = |store: &str, root: &str| results::MirrorBondKey {
store_id: store.into(),
root: root.into(),
};
let with = |k: results::MirrorBondKey| MirrorBondStatesParams {
after: Some(k),
..MirrorBondStatesParams::default()
};
let canonical = with(key(STORE, ROOT)).validated().expect("canonical key");
assert_eq!(canonical.after, Some(key(STORE, ROOT)));
let prefixed = with(key(&format!("0x{STORE}"), &format!("0x{ROOT}")))
.validated()
.expect("a 0x-prefixed key is tolerated");
assert_eq!(prefixed.after, Some(key(STORE, ROOT)));
assert_eq!(
serde_json::to_value(&prefixed).unwrap()["after"],
json!({"store_id": STORE, "root": ROOT}),
"the normalized form is what goes back on the wire"
);
let uppercase = STORE.to_ascii_uppercase();
let short = &STORE[1..];
let long = format!("{STORE}a");
let non_hex = "z".repeat(64);
let bad: [&str; 6] = [&uppercase, short, &long, &non_hex, "", "0xnothex"];
for spelling in bad {
for candidate in [key(spelling, ROOT), key(STORE, spelling)] {
let err = with(candidate.clone())
.validated()
.expect_err("a malformed cursor must be refused");
assert_eq!(err.code_enum(), Some(ControlErrorCode::InvalidParams));
let wire = json!({"after": {
"store_id": candidate.store_id, "root": candidate.root
}});
let decoded = serde_json::from_value::<MirrorBondStatesParams>(wire);
assert!(
decoded.is_err(),
"a malformed cursor must not decode into a request at all"
);
assert!(
decoded.map(|p| p.after).unwrap_or(None).is_none(),
"an unparseable cursor must never become start-of-set"
);
}
}
}