use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheView {
pub cap_bytes: u64,
pub used_bytes: u64,
pub dir: String,
pub shared: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyncAvailability {
pub available: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SoftwareVersionDetail {
#[default]
Full,
Minor,
Off,
}
impl SoftwareVersionDetail {
pub fn render(self, product: &str, version: &semver::Version) -> String {
match self {
Self::Full => format!("{product}/{version}"),
Self::Minor => {
let coarsened = semver::Version::new(version.major, version.minor, 0);
if is_version_zero(&coarsened) {
return String::new();
}
format!("{product}/{coarsened}")
}
Self::Off => String::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PeerSoftware {
Unknown,
Reported {
product: String,
version: semver::Version,
raw: String,
},
}
fn is_version_zero(version: &semver::Version) -> bool {
version.major == 0 && version.minor == 0 && version.patch == 0
}
const PRODUCT_VERSION_SEPARATOR: char = '/';
impl PeerSoftware {
pub fn parse(advertised: &str) -> Self {
let raw = advertised.trim();
let Some((product, version)) = raw.rsplit_once(PRODUCT_VERSION_SEPARATOR) else {
return Self::Unknown;
};
if product.is_empty() {
return Self::Unknown;
}
let Ok(version) = version.parse::<semver::Version>() else {
return Self::Unknown;
};
if is_version_zero(&version) {
return Self::Unknown;
}
Self::Reported {
product: product.to_string(),
version,
raw: raw.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusResult {
pub running: bool,
pub service: String,
pub version: String,
pub commit: String,
pub protocol: String,
pub uptime_secs: u64,
pub addr: String,
pub upstream: String,
pub cache: CacheView,
pub hosted_store_count: u64,
pub cached_capsule_count: u64,
pub pinned_store_count: u64,
pub sync: SyncAvailability,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfigResult {
pub addr: String,
pub port: String,
pub upstream: String,
pub upstream_override: Option<String>,
pub cache_dir: String,
pub cache_shared: bool,
pub config_path: String,
pub sync_available: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetUpstreamResult {
pub upstream: String,
pub requires_restart: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetLevelResult {
pub filter: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetCapResult {
pub cap_bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheClearResult {
pub cleared: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapsuleEntry {
pub capsule: String,
pub root: String,
pub size_bytes: u64,
pub last_used_unix_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostedStore {
pub store_id: String,
pub pinned: bool,
pub capsule_count: u64,
pub total_bytes: u64,
pub capsules: Vec<CapsuleEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostedStoresListResult {
pub stores: Vec<HostedStore>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PinResult {
pub store_id: String,
pub root: Option<String>,
pub pinned: bool,
pub fetch: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnpinResult {
pub store_id: String,
pub unpinned: bool,
pub evicted_capsules: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostedStoreStatusResult {
pub store_id: String,
pub pinned: bool,
pub capsule_count: u64,
pub total_bytes: u64,
pub capsules: Vec<CapsuleEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapsuleFetchResult {
pub store: String,
pub root: String,
pub status: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyncStatusResult {
pub available: bool,
pub method: String,
pub pinned_total: u64,
pub pinned_synced: u64,
pub whole_store_trigger_supported: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyncTriggerResult {
pub store_id: String,
pub root: String,
pub status: String,
pub size_bytes: u64,
pub served_root: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairingApproveResult {
pub approved: bool,
pub client_name: String,
pub token_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairingRevokeResult {
pub revoked: bool,
pub token_id: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerCountsResult {
pub dig_peer_count: Option<u32>,
pub chia_peer_count: Option<u32>,
pub known_dig_peer_count: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeersConnectResult {
pub connected: bool,
pub peer_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeersDisconnectResult {
pub disconnected: bool,
pub peer_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChiaPeerEntry {
pub ip: String,
pub port: u16,
pub peak_height: Option<u32>,
pub user_managed: bool,
pub banned: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChiaPeersListResult {
pub peers: Vec<ChiaPeerEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChiaPeersAddResult {
pub added: bool,
pub ip: String,
pub port: u16,
pub corroboration_bypassed: bool,
pub notice: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChiaPeerRemovalOutcome {
Removed,
NoSuchPeer,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChiaPeersRemoveResult {
pub outcome: ChiaPeerRemovalOutcome,
pub ip: String,
pub banned: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubscribeResult {
pub subscribed: bool,
pub added: bool,
pub store_id: String,
#[serde(default)]
pub kind: crate::params::SubscriptionKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnsubscribeResult {
pub subscribed: bool,
pub removed: bool,
pub store_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListSubscriptionsResult {
pub subscriptions: Vec<String>,
pub count: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletBalanceResult {
pub balance: u64,
pub pending: u64,
pub source: Option<WalletReadSource>,
pub synced: bool,
pub peak_height: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WalletReadSource {
Db,
Fallback,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletCoinRecord {
pub coin_id: String,
pub asset: Option<crate::params::Asset>,
pub amount: u64,
pub parent_coin_info: String,
pub puzzle_hash: String,
pub created_height: Option<u32>,
pub spent_height: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletCoinsResult {
pub coins: Vec<WalletCoinRecord>,
pub source: Option<WalletReadSource>,
pub synced: bool,
pub peak_height: Option<u32>,
}
fn required_option<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<T>::deserialize(deserializer)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletCoinByIdResult {
#[serde(deserialize_with = "required_option")]
pub coin: Option<WalletCoinRecord>,
pub source: Option<WalletReadSource>,
pub synced: bool,
pub peak_height: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletCoinSpend {
pub coin: WalletCoinRecord,
pub puzzle_reveal: String,
pub solution: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletCoinSpendResult {
#[serde(deserialize_with = "required_option")]
pub spend: Option<WalletCoinSpend>,
pub source: Option<WalletReadSource>,
pub synced: bool,
pub peak_height: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletCoinsByParentResult {
pub coins: Vec<WalletCoinRecord>,
pub complete: bool,
#[serde(deserialize_with = "required_option")]
pub cursor: Option<String>,
pub source: Option<WalletReadSource>,
pub synced: bool,
pub peak_height: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletPeakResult {
pub peak_height: Option<u32>,
pub synced: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum WalletSyncPhase {
NotStarted,
Syncing,
Synced,
NoWalletEnrolled,
WalletNotUnlocked,
Unrecognized(UnknownPhaseToken),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct UnknownPhaseToken(String);
impl UnknownPhaseToken {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn display_bounded(&self, max_len: usize) -> String {
let mut rendered = String::new();
let mut dropped = false;
for character in self.0.chars() {
let escaped: String = character.escape_debug().collect();
if rendered.len() + escaped.len() > max_len {
dropped = true;
break;
}
rendered.push_str(&escaped);
}
if dropped {
rendered.push('…');
}
rendered
}
}
impl std::fmt::Display for UnknownPhaseToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for character in self.0.chars() {
write!(f, "{}", character.escape_debug())?;
}
Ok(())
}
}
impl WalletSyncPhase {
pub const ALL: &'static [WalletSyncPhase] = &[
WalletSyncPhase::NotStarted,
WalletSyncPhase::Syncing,
WalletSyncPhase::Synced,
WalletSyncPhase::NoWalletEnrolled,
WalletSyncPhase::WalletNotUnlocked,
];
pub fn as_wire(&self) -> &str {
match self {
WalletSyncPhase::NotStarted => "not_started",
WalletSyncPhase::Syncing => "syncing",
WalletSyncPhase::Synced => "synced",
WalletSyncPhase::NoWalletEnrolled => "no_wallet_enrolled",
WalletSyncPhase::WalletNotUnlocked => "wallet_not_unlocked",
WalletSyncPhase::Unrecognized(token) => token.as_str(),
}
}
pub fn unrecognized_token(&self) -> Option<&str> {
match self {
WalletSyncPhase::Unrecognized(token) => Some(token.as_str()),
_ => None,
}
}
pub fn is_recognized(&self) -> bool {
!matches!(self, WalletSyncPhase::Unrecognized(_))
}
pub fn unrecognized_token_value(&self) -> Option<&UnknownPhaseToken> {
match self {
WalletSyncPhase::Unrecognized(token) => Some(token),
_ => None,
}
}
pub fn may_render_as_settled(&self) -> bool {
match self {
WalletSyncPhase::Synced | WalletSyncPhase::NoWalletEnrolled => true,
WalletSyncPhase::NotStarted
| WalletSyncPhase::Syncing
| WalletSyncPhase::WalletNotUnlocked
| WalletSyncPhase::Unrecognized(_) => false,
}
}
}
impl From<&str> for WalletSyncPhase {
fn from(token: &str) -> Self {
match token {
"not_started" => WalletSyncPhase::NotStarted,
"syncing" => WalletSyncPhase::Syncing,
"synced" => WalletSyncPhase::Synced,
"no_wallet_enrolled" => WalletSyncPhase::NoWalletEnrolled,
"wallet_not_unlocked" => WalletSyncPhase::WalletNotUnlocked,
other => WalletSyncPhase::Unrecognized(UnknownPhaseToken(other.to_owned())),
}
}
}
impl Serialize for WalletSyncPhase {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_wire())
}
}
impl<'de> Deserialize<'de> for WalletSyncPhase {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let token = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
Ok(WalletSyncPhase::from(token.as_ref()))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletSyncStatusResult {
pub phase: WalletSyncPhase,
pub peak_height: Option<u32>,
pub chia_peer_count: Option<u32>,
pub watched_addresses: Option<u32>,
pub subscription_peer_count: Option<u32>,
pub chia_peer_peak_height: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletBroadcastResult {
pub accepted: bool,
pub transaction_id: Option<String>,
pub rejection: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletWatchResult {
pub added: u32,
pub watched: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletUnwatchResult {
pub removed: u32,
pub watched: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletWatchedResult {
pub public_keys: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReservedCoin {
pub coin_id: String,
pub reservation_id: String,
pub expires_at_unix: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletReservationsHeldResult {
pub reserved: Vec<ReservedCoin>,
pub as_of_unix: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletReservationsReserveResult {
pub reservation_id: String,
pub coin_ids: Vec<String>,
pub expires_at_unix: u64,
pub ttl_secs: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletReservationsReleaseResult {
pub released: bool,
pub coin_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairingRequestResult {
pub pairing_id: String,
pub pairing_code: String,
pub expires_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairingPollResult {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub token: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletArrivalRecord {
pub seq: u64,
pub coin_id: String,
pub puzzle_hash: String,
pub amount: String,
pub asset_id: Option<String>,
pub confirmed_height: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletArrivalsResult {
pub arrivals: Vec<WalletArrivalRecord>,
pub cursor: u64,
pub latest: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfilePutBodyResult {
pub stored: bool,
pub store_id: String,
pub root: String,
pub body_bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileGetBodyResult {
pub store_id: String,
pub root: String,
pub body_b64: Option<String>,
pub body_bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "asset", rename_all = "snake_case")]
pub enum SpendAsset {
Xch,
Dig,
Cat {
asset_id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpendAuthority {
pub principal: String,
pub grant: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SpendFailureStage {
Signing,
Broadcast,
Confirmation,
}
impl SpendFailureStage {
pub fn money_may_have_moved(self) -> bool {
match self {
SpendFailureStage::Signing => false,
SpendFailureStage::Broadcast | SpendFailureStage::Confirmation => true,
}
}
pub const fn token(self) -> &'static str {
match self {
SpendFailureStage::Signing => "signing",
SpendFailureStage::Broadcast => "broadcast",
SpendFailureStage::Confirmation => "confirmation",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum SpendOutcome {
Pending,
Submitted,
Confirmed {
height: u32,
coin_id: String,
},
Failed {
stage: SpendFailureStage,
reason: String,
},
Unresolved {
reason: String,
},
}
impl SpendOutcome {
pub const fn token(&self) -> &'static str {
match self {
SpendOutcome::Pending => "pending",
SpendOutcome::Submitted => "submitted",
SpendOutcome::Confirmed { .. } => "confirmed",
SpendOutcome::Failed { .. } => "failed",
SpendOutcome::Unresolved { .. } => "unresolved",
}
}
pub fn outcome_is_unknown(&self) -> bool {
match self {
SpendOutcome::Unresolved { .. } => true,
SpendOutcome::Failed { stage, .. } => stage.money_may_have_moved(),
SpendOutcome::Pending | SpendOutcome::Submitted | SpendOutcome::Confirmed { .. } => {
false
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpendChainReference {
pub coin_id: String,
pub confirmed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AutomatedSpend {
pub id: String,
pub revision: u32,
pub kind: String,
pub purpose: String,
pub authority: SpendAuthority,
pub asset: SpendAsset,
pub amount_mojos: String,
pub fee_mojos: String,
pub store_id: Option<String>,
pub initiated_ms: u64,
pub updated_ms: u64,
pub status: SpendOutcome,
pub funding_coin_ids: Vec<String>,
#[serde(deserialize_with = "required_option")]
pub chain_reference: Option<SpendChainReference>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpendsListResult {
pub spends: Vec<AutomatedSpend>,
pub complete: bool,
#[serde(deserialize_with = "required_option")]
pub cursor: Option<String>,
pub unreadable_lines: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn status_result_round_trips_the_node_shape() {
let v = json!({
"running": true, "service": "dig-node", "version": "0.30.0", "commit": "abc",
"protocol": "21", "uptime_secs": 5, "addr": "127.0.0.1:9256", "upstream": "https://rpc.dig.net",
"cache": {"cap_bytes": 1024, "used_bytes": 10, "dir": "/c", "shared": false},
"hosted_store_count": 2, "cached_capsule_count": 3, "pinned_store_count": 1,
"sync": {"available": true}
});
let parsed: StatusResult = serde_json::from_value(v.clone()).unwrap();
assert_eq!(serde_json::to_value(&parsed).unwrap(), v);
}
#[test]
fn config_result_keeps_upstream_override_null_when_unset() {
let parsed = ConfigResult {
addr: "127.0.0.1:9256".into(),
port: "9256".into(),
upstream: "https://rpc.dig.net".into(),
upstream_override: None,
cache_dir: "/c".into(),
cache_shared: false,
config_path: "/c/config.json".into(),
sync_available: true,
};
let v = serde_json::to_value(&parsed).unwrap();
assert_eq!(v["upstream_override"], json!(null));
assert!(v.as_object().unwrap().contains_key("upstream_override"));
}
#[test]
fn pairing_poll_omits_token_until_approved() {
let pending = PairingPollResult {
status: "pending".into(),
token: None,
};
let v = serde_json::to_value(&pending).unwrap();
assert_eq!(v, json!({"status": "pending"}));
let approved = PairingPollResult {
status: "approved".into(),
token: Some("deadbeef".into()),
};
assert_eq!(
serde_json::to_value(&approved).unwrap(),
json!({"status": "approved", "token": "deadbeef"})
);
}
#[test]
fn unknown_covers_empty_the_legacy_sentinel_and_garbage() {
for raw in [
"", "0.0.0", " ", "dig-node", "dig-node/", "dig-node/not-a-version", "/1.2.3", "1.2.3", "dig-node/0.0.0", ] {
assert_eq!(
PeerSoftware::parse(raw),
PeerSoftware::Unknown,
"{raw:?} must map to Unknown"
);
}
}
#[test]
fn reported_carries_product_version_and_the_raw_advertisement() {
let parsed = PeerSoftware::parse("dig-node/0.99.1");
let PeerSoftware::Reported {
product,
version,
raw,
} = parsed
else {
panic!("a well-formed advertisement must be Reported");
};
assert_eq!(product, "dig-node");
assert_eq!(version, semver::Version::new(0, 99, 1));
assert_eq!(raw, "dig-node/0.99.1");
}
#[test]
fn product_is_split_at_the_last_separator() {
let PeerSoftware::Reported {
product, version, ..
} = PeerSoftware::parse("acme/dig-node/1.2.3")
else {
panic!("expected Reported");
};
assert_eq!(product, "acme/dig-node");
assert_eq!(version, semver::Version::new(1, 2, 3));
}
#[test]
fn surrounding_whitespace_is_trimmed_before_parsing() {
let PeerSoftware::Reported {
product,
version,
raw,
} = PeerSoftware::parse(" dig-node/1.2.3 ")
else {
panic!("a padded advertisement must still be Reported");
};
assert_eq!(product, "dig-node");
assert_eq!(version, semver::Version::new(1, 2, 3));
assert_eq!(raw, "dig-node/1.2.3", "raw must record the trimmed value");
}
#[test]
fn prerelease_versions_are_preserved() {
let PeerSoftware::Reported { version, raw, .. } =
PeerSoftware::parse("dig-node/1.0.0-nightly.20260805")
else {
panic!("expected Reported");
};
assert_eq!(version.to_string(), "1.0.0-nightly.20260805");
assert_eq!(raw, "dig-node/1.0.0-nightly.20260805");
}
#[test]
fn unknown_serializes_as_a_tagged_object_with_no_version_field() {
let v = serde_json::to_value(PeerSoftware::Unknown).unwrap();
assert_eq!(v, json!({"kind": "unknown"}));
assert!(
v.get("version").is_none(),
"Unknown must not carry a version field at all"
);
}
#[test]
fn both_variants_round_trip_byte_identically() {
for wire in [
json!({"kind": "unknown"}),
json!({
"kind": "reported",
"product": "dig-node",
"version": "0.99.1",
"raw": "dig-node/0.99.1"
}),
] {
let parsed: PeerSoftware = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(serde_json::to_value(&parsed).unwrap(), wire);
}
}
#[test]
fn parse_then_serialize_matches_the_documented_json() {
assert_eq!(
serde_json::to_value(PeerSoftware::parse("dig-node/0.99.1")).unwrap(),
json!({
"kind": "reported",
"product": "dig-node",
"version": "0.99.1",
"raw": "dig-node/0.99.1"
})
);
assert_eq!(
serde_json::to_value(PeerSoftware::parse("0.0.0")).unwrap(),
json!({"kind": "unknown"})
);
}
struct Probe<T>(core::marker::PhantomData<T>);
trait ProbeFallback {
fn is_ord() -> bool {
false
}
}
impl<T> ProbeFallback for Probe<T> {}
impl<T: Ord> Probe<T> {
fn is_ord() -> bool {
true
}
}
struct PartialOrdProbe<T>(core::marker::PhantomData<T>);
trait PartialOrdFallback {
fn is_partial_ord() -> bool {
false
}
}
impl<T> PartialOrdFallback for PartialOrdProbe<T> {}
impl<T: PartialOrd> PartialOrdProbe<T> {
fn is_partial_ord() -> bool {
true
}
}
#[test]
fn peer_software_is_not_ordered() {
assert!(
Probe::<u32>::is_ord(),
"control: the probe must detect a type that IS Ord, or it proves nothing"
);
assert!(
!Probe::<PeerSoftware>::is_ord(),
"PeerSoftware must not implement Ord — comparison belongs after destructuring Reported"
);
}
#[test]
fn peer_software_has_no_default() {
struct DefaultProbe<T>(core::marker::PhantomData<T>);
trait DefaultFallback {
fn is_default() -> bool {
false
}
}
impl<T> DefaultFallback for DefaultProbe<T> {}
impl<T: Default> DefaultProbe<T> {
fn is_default() -> bool {
true
}
}
assert!(
DefaultProbe::<String>::is_default(),
"control: the probe must detect a type that IS Default, or it proves nothing"
);
assert!(
!DefaultProbe::<PeerSoftware>::is_default(),
"PeerSoftware must not implement Default"
);
}
#[test]
fn each_detail_mode_round_trips_to_the_intended_precision() {
let v = semver::Version::new(0, 99, 1);
let full = SoftwareVersionDetail::Full.render("dig-node", &v);
assert_eq!(full, "dig-node/0.99.1");
assert_eq!(
PeerSoftware::parse(&full),
PeerSoftware::parse("dig-node/0.99.1")
);
let minor = SoftwareVersionDetail::Minor.render("dig-node", &v);
assert_ne!(minor, full, "Minor must actually coarsen");
let PeerSoftware::Reported { version, .. } = PeerSoftware::parse(&minor) else {
panic!("a coarsened advertisement must still be READABLE, not Unknown");
};
assert_eq!(version.major, 0);
assert_eq!(version.minor, 99);
assert_eq!(version.patch, 0, "the patch level is what Minor hides");
let off = SoftwareVersionDetail::Off.render("dig-node", &v);
assert_eq!(off, "");
assert_eq!(PeerSoftware::parse(&off), PeerSoftware::Unknown);
}
#[test]
fn minor_mode_stays_valid_semver_rather_than_collapsing_to_unknown() {
let rendered =
SoftwareVersionDetail::Minor.render("dig-node", &semver::Version::new(1, 4, 7));
assert_eq!(rendered, "dig-node/1.4.0");
assert_ne!(
PeerSoftware::parse(&rendered),
PeerSoftware::Unknown,
"a coarsened build must remain readable; `product/1.4` would not be"
);
}
#[test]
fn minor_mode_strips_prerelease_and_build_metadata() {
let v: semver::Version = "1.0.0-nightly.20260805+sha.abc123".parse().unwrap();
let rendered = SoftwareVersionDetail::Minor.render("dig-node", &v);
assert_eq!(rendered, "dig-node/1.0.0");
assert!(
!rendered.contains("nightly"),
"the nightly identifier must not survive coarsening"
);
assert!(
!rendered.contains("abc123"),
"build metadata must not survive coarsening"
);
}
#[test]
fn off_mode_reveals_nothing_for_any_version() {
for v in ["0.0.1", "1.2.3", "99.99.99-rc.1"] {
let rendered = SoftwareVersionDetail::Off.render("dig-node", &v.parse().unwrap());
assert_eq!(
rendered, "",
"Off must reveal nothing, including the product name"
);
}
}
#[test]
fn detail_defaults_to_full() {
assert_eq!(
SoftwareVersionDetail::default(),
SoftwareVersionDetail::Full
);
}
#[test]
fn detail_uses_lowercase_wire_tokens() {
for (mode, token) in [
(SoftwareVersionDetail::Full, "\"full\""),
(SoftwareVersionDetail::Minor, "\"minor\""),
(SoftwareVersionDetail::Off, "\"off\""),
] {
assert_eq!(serde_json::to_string(&mode).unwrap(), token);
assert_eq!(
serde_json::from_str::<SoftwareVersionDetail>(token).unwrap(),
mode
);
}
}
#[test]
fn peer_software_is_not_partially_ordered_either() {
assert!(
PartialOrdProbe::<f64>::is_partial_ord(),
"control: the probe must detect a type that IS PartialOrd but NOT Ord, or it proves nothing about the gap between the two"
);
assert!(
PartialOrdProbe::<u32>::is_partial_ord(),
"control: a fully-ordered type must also be detected"
);
assert!(
!PartialOrdProbe::<PeerSoftware>::is_partial_ord(),
"PeerSoftware must implement neither PartialOrd nor Ord"
);
}
#[test]
fn version_zero_is_unknown_however_it_is_decorated() {
for raw in [
"dig-node/0.0.0",
"dig-node/0.0.0+build",
"dig-node/0.0.0-rc.1",
"x/0.0.0-0",
"dig-node/0.0.0-alpha+sha.abc123",
] {
assert_eq!(
PeerSoftware::parse(raw),
PeerSoftware::Unknown,
"{raw:?} is version zero and must be Unknown"
);
}
}
#[test]
fn a_nonzero_version_near_zero_is_still_reported() {
for raw in ["dig-node/0.0.1", "dig-node/0.1.0", "dig-node/0.0.1-rc.1"] {
assert_ne!(
PeerSoftware::parse(raw),
PeerSoftware::Unknown,
"{raw:?} is a real build, not the sentinel"
);
}
}
#[test]
fn every_rendering_is_empty_or_readable() {
let versions = [
"0.0.1",
"0.0.7",
"0.0.99", "0.1.0",
"0.99.1",
"1.0.0",
"1.4.7",
"10.20.30",
"1.0.0-nightly.20260805+sha.abc123",
"0.0.1-rc.1",
];
for mode in [
SoftwareVersionDetail::Full,
SoftwareVersionDetail::Minor,
SoftwareVersionDetail::Off,
] {
for v in versions {
let rendered = mode.render("dig-node", &v.parse().unwrap());
if rendered.is_empty() {
continue;
}
assert_ne!(
PeerSoftware::parse(&rendered),
PeerSoftware::Unknown,
"{mode:?} rendered {rendered:?} for {v}, which reads back as Unknown — a non-empty rendering must always be readable"
);
}
}
}
#[test]
fn minor_of_a_zero_zero_build_advertises_nothing_rather_than_the_sentinel() {
let rendered = SoftwareVersionDetail::Minor.render("dig-node", &"0.0.7".parse().unwrap());
assert_eq!(rendered, "");
assert_ne!(
rendered, "dig-node/0.0.0",
"the sentinel must never be ADVERTISED; it is only ever received from a legacy peer"
);
}
#[test]
fn raw_is_still_reconstructible_from_the_parsed_parts() {
for advertised in [
"dig-node/0.0.1",
"dig-node/0.99.1",
"dig-node/1.0.0-nightly.20260805",
"dig-node/1.0.0+sha.abc123",
"dig-node/1.0.0-rc.1+build.7",
"acme/dig-node/1.2.3",
] {
let PeerSoftware::Reported {
product,
version,
raw,
} = PeerSoftware::parse(advertised)
else {
panic!("{advertised:?} must be Reported");
};
assert_eq!(
raw,
format!("{product}/{version}"),
"raw diverged from the parsed parts for {advertised:?} — `raw` is now load-bearing; see this test's doc comment before changing anything"
);
}
}
#[test]
fn a_removal_that_matched_nothing_is_not_serialised_as_a_removal() {
let removed = ChiaPeersRemoveResult {
outcome: ChiaPeerRemovalOutcome::Removed,
ip: "203.0.113.7".into(),
banned: false,
};
let missed = ChiaPeersRemoveResult {
outcome: ChiaPeerRemovalOutcome::NoSuchPeer,
..removed.clone()
};
let a = serde_json::to_value(&removed).unwrap();
let b = serde_json::to_value(&missed).unwrap();
assert_ne!(a, b, "the two outcomes must differ on the wire");
assert_eq!(a["outcome"], "removed");
assert_eq!(b["outcome"], "no_such_peer");
let miss_obj = b.as_object().unwrap();
assert!(
!miss_obj
.values()
.any(|v| v == &serde_json::Value::Bool(true)),
"a miss must carry no `true` a client can mistake for success: {b}"
);
let back: ChiaPeersRemoveResult = serde_json::from_value(b).unwrap();
assert_eq!(back.outcome, ChiaPeerRemovalOutcome::NoSuchPeer);
}
#[test]
fn an_unobserved_peak_is_null_and_an_observed_zero_is_not() {
let entry = |peak| ChiaPeerEntry {
ip: "203.0.113.7".into(),
port: 8444,
peak_height: peak,
user_managed: true,
banned: false,
};
let unobserved = serde_json::to_value(entry(None)).unwrap();
let genesis = serde_json::to_value(entry(Some(0))).unwrap();
assert!(
unobserved.get("peak_height").is_some(),
"the key must be PRESENT and null, not omitted: {unobserved}"
);
assert_eq!(unobserved["peak_height"], serde_json::Value::Null);
assert_eq!(genesis["peak_height"], 0);
assert_ne!(
unobserved["peak_height"], genesis["peak_height"],
"unobservable and observed-zero must not render the same"
);
}
#[test]
fn the_peer_list_can_carry_a_banned_entry() {
let listed = ChiaPeersListResult {
peers: vec![ChiaPeerEntry {
ip: "203.0.113.9".into(),
port: 8444,
peak_height: None,
user_managed: false,
banned: true,
}],
};
let json = serde_json::to_value(&listed).unwrap();
assert_eq!(json["peers"][0]["banned"], true);
let back: ChiaPeersListResult = serde_json::from_value(json).unwrap();
assert!(back.peers[0].banned);
}
#[test]
fn the_add_result_carries_a_quotable_bypass_notice() {
let json = serde_json::to_value(ChiaPeersAddResult {
added: true,
ip: "203.0.113.7".into(),
port: 8444,
corroboration_bypassed: true,
notice: "believed WITHOUT corroboration".into(),
})
.unwrap();
let notice = json["notice"]
.as_str()
.expect("notice is a string on the wire");
assert!(
!notice.trim().is_empty(),
"an empty notice discloses nothing"
);
assert!(
notice.to_lowercase().contains("corroboration"),
"the notice must name the cost it exists to disclose: {notice}"
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CollateralUnknownReason {
NotCensused,
BehindFinalityDepth,
RecordUnreadable,
NoChainSource,
}
impl CollateralUnknownReason {
pub const ALL: &'static [CollateralUnknownReason] = &[
CollateralUnknownReason::NotCensused,
CollateralUnknownReason::BehindFinalityDepth,
CollateralUnknownReason::RecordUnreadable,
CollateralUnknownReason::NoChainSource,
];
pub const fn as_wire(self) -> &'static str {
match self {
CollateralUnknownReason::NotCensused => "not_censused",
CollateralUnknownReason::BehindFinalityDepth => "behind_finality_depth",
CollateralUnknownReason::RecordUnreadable => "record_unreadable",
CollateralUnknownReason::NoChainSource => "no_chain_source",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum CollateralRequirementResult {
Known {
epoch: u64,
protocol_version: u16,
required_per_store_dig_base_units: u64,
stores: u64,
owners: u64,
multiplier_micros: u64,
handicap_dig_base_units: u64,
},
Unknown {
reason: CollateralUnknownReason,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CollateralFundingState {
ShortNow,
DangerouslyLow,
BelowRecommendedBuffer,
Funded,
}
impl CollateralFundingState {
pub const ALL: &'static [CollateralFundingState] = &[
CollateralFundingState::ShortNow,
CollateralFundingState::DangerouslyLow,
CollateralFundingState::BelowRecommendedBuffer,
CollateralFundingState::Funded,
];
pub const fn as_wire(self) -> &'static str {
match self {
CollateralFundingState::ShortNow => "short_now",
CollateralFundingState::DangerouslyLow => "dangerously_low",
CollateralFundingState::BelowRecommendedBuffer => "below_recommended_buffer",
CollateralFundingState::Funded => "funded",
}
}
pub const fn is_shortfall(self) -> bool {
matches!(
self,
CollateralFundingState::ShortNow | CollateralFundingState::DangerouslyLow
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CollateralBufferUnknownReason {
RequirementUnknown,
ServedSetUnknown,
ReclaimStateUnknown,
BalanceUnknown,
}
impl CollateralBufferUnknownReason {
pub const ALL: &'static [CollateralBufferUnknownReason] = &[
CollateralBufferUnknownReason::RequirementUnknown,
CollateralBufferUnknownReason::ServedSetUnknown,
CollateralBufferUnknownReason::ReclaimStateUnknown,
CollateralBufferUnknownReason::BalanceUnknown,
];
pub const fn as_wire(self) -> &'static str {
match self {
CollateralBufferUnknownReason::RequirementUnknown => "requirement_unknown",
CollateralBufferUnknownReason::ServedSetUnknown => "served_set_unknown",
CollateralBufferUnknownReason::ReclaimStateUnknown => "reclaim_state_unknown",
CollateralBufferUnknownReason::BalanceUnknown => "balance_unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum CollateralBufferResult {
Known {
epoch: u64,
protocol_version: u16,
funding_state: CollateralFundingState,
recommended_buffer_dig_base_units: u64,
spendable_dig_base_units: u64,
pairs_served_by_this_node: u64,
required_per_store_dig_base_units: u64,
margin_bp: u64,
overlap_dig_base_units: u64,
escalation_headroom_dig_base_units: u64,
horizon_epochs: u32,
escalation_ceiling_micros: u64,
},
Unknown {
reason: CollateralBufferUnknownReason,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CollateralMarginResult {
pub margin_bp: u64,
}