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 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(
&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(),
},
json!({"jsonrpc":"2.0","id":1,"method":"control.subscribe","params":{"store_id":STORE}}),
);
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 {
address: "xch1exampleaddr".into(),
asset: Asset::Xch,
},
json!({"jsonrpc":"2.0","id":1,"method":"control.wallet.coins","params":{"address":"xch1exampleaddr","asset":"xch"}}),
);
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::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
}));
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
}],
"source": "db", "synced": true, "peak_height": 5_000_000u32
}));
assert_result_round_trips::<results::WalletCoinsResult>(json!({
"coins": [], "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
}));
assert_result_round_trips::<results::WalletSyncStatusResult>(json!({
"phase": "syncing", "peak_height": 4_000_000u32, "chia_peer_count": 3u32,
"watched_addresses": 12u32
}));
assert_result_round_trips::<results::WalletSyncStatusResult>(json!({
"phase": "synced", "peak_height": 5_000_000u32, "chia_peer_count": 5u32,
"watched_addresses": 12u32
}));
assert_result_round_trips::<results::WalletSyncStatusResult>(json!({
"phase": "not_started", "peak_height": 4_900_000u32, "chia_peer_count": 0u32,
"watched_addresses": 12u32
}));
assert_result_round_trips::<results::WalletSyncStatusResult>(json!({
"phase": "syncing", "peak_height": null, "chia_peer_count": null,
"watched_addresses": null
}));
assert_result_round_trips::<results::WalletSyncStatusResult>(json!({
"phase": "no_wallet_enrolled", "peak_height": null, "chia_peer_count": 0u32,
"watched_addresses": 0u32
}));
assert_result_round_trips::<results::WalletSyncStatusResult>(json!({
"phase": "wallet_not_unlocked", "peak_height": 4_900_000u32, "chia_peer_count": 2u32,
"watched_addresses": 0u32
}));
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
}));
assert_result_round_trips::<results::PeerCountsResult>(json!({
"dig_peer_count": 6u32, "chia_peer_count": 3u32
}));
assert_result_round_trips::<results::PeerCountsResult>(json!({
"dig_peer_count": 0u32, "chia_peer_count": 0u32
}));
assert_result_round_trips::<results::PeerCountsResult>(json!({
"dig_peer_count": null, "chia_peer_count": null
}));
assert_result_round_trips::<results::PeerCountsResult>(json!({
"dig_peer_count": 6u32, "chia_peer_count": null
}));
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),
})
.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),
})
.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),
})
.unwrap();
assert_eq!(wire["dig_peer_count"], json!(6));
assert_eq!(wire["chia_peer_count"], json!(3));
let keys: Vec<&str> = wire
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(keys, vec!["chia_peer_count", "dig_peer_count"]);
assert_eq!(
serde_json::to_string(&results::PeerCountsResult {
dig_peer_count: Some(6),
chia_peer_count: Some(3),
})
.unwrap(),
r#"{"dig_peer_count":6,"chia_peer_count":3}"#,
"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),
})
.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),
})
.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),
})
.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,
})
.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"}
}
})
);
}
struct MockNode;
#[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 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 subscribe(
&self,
params: SubscribeParams,
) -> Result<results::SubscribeResult, ControlError> {
Ok(results::SubscribeResult {
subscribed: true,
added: true,
store_id: params.store_id,
})
}
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> {
Ok(results::WalletCoinsResult {
coins: vec![results::WalletCoinRecord {
coin_id: params.address,
asset: Some(params.asset),
amount: match params.asset {
Asset::Xch => 1,
Asset::Dig => 2,
},
parent_coin_info: "11".repeat(32),
puzzle_hash: "22".repeat(32),
created_height: Some(5_000_000),
spent_height: None,
}],
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 peer_counts(&self) -> Result<results::PeerCountsResult, ControlError> {
Ok(results::PeerCountsResult {
dig_peer_count: Some(6),
chia_peer_count: Some(3),
})
}
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),
})
}
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 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 sub = round_trip(&SubscribeParams {
store_id: STORE.into(),
})
.unwrap();
assert!(sub.added);
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"
);
}
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::UpdaterSetChannel => json!({"channel": "stable"}),
ControlMethod::UpdaterPause => json!({}),
ControlMethod::PairingApprove => json!({"pairing_id": "x"}),
ControlMethod::PairingRevoke => json!({"token_id": "x"}),
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"}),
_ => json!({}),
}
}
#[test]
fn the_dispatcher_routes_each_wallet_chain_method_to_its_own_handler() {
let coins = round_trip(&WalletCoinsParams {
address: "xch1mintfunder".into(),
asset: Asset::Dig,
})
.expect("coins must route");
assert_eq!(coins.coins[0].coin_id, "xch1mintfunder");
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 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,
}],
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,
}],
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 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),
};
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:?}"
);
}
}
}