use serde::{Deserialize, Serialize};
use crate::error::{ControlError, ControlErrorCode};
use crate::method::ControlMethod;
use crate::results;
use crate::traits::ControlCall;
macro_rules! control_call {
($ty:ty => $method:expr, $out:ty) => {
impl ControlCall for $ty {
const METHOD: ControlMethod = $method;
type Output = $out;
}
};
}
macro_rules! no_params {
($(#[$doc:meta])* $name:ident => $method:expr, $out:ty) => {
$(#[$doc])*
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct $name {}
control_call!($name => $method, $out);
};
}
no_params!(
StatusParams => ControlMethod::Status, results::StatusResult
);
no_params!(
ConfigGetParams => ControlMethod::ConfigGet, results::ConfigResult
);
no_params!(
CacheGetParams => ControlMethod::CacheGet, results::CacheView
);
no_params!(
CacheClearParams => ControlMethod::CacheClear, results::CacheClearResult
);
no_params!(
HostedStoresListParams => ControlMethod::HostedStoresList, results::HostedStoresListResult
);
no_params!(
SyncStatusParams => ControlMethod::SyncStatus, results::SyncStatusResult
);
no_params!(
UpdaterStatusParams => ControlMethod::UpdaterStatus, serde_json::Value
);
no_params!(
UpdaterResumeParams => ControlMethod::UpdaterResume, serde_json::Value
);
no_params!(
UpdaterCheckNowParams => ControlMethod::UpdaterCheckNow, serde_json::Value
);
no_params!(
PairingListParams => ControlMethod::PairingList, serde_json::Value
);
no_params!(
PeerStatusParams => ControlMethod::PeerStatus, serde_json::Value
);
no_params!(
ListSubscriptionsParams => ControlMethod::ListSubscriptions, results::ListSubscriptionsResult
);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetUpstreamParams {
pub upstream: String,
}
control_call!(SetUpstreamParams => ControlMethod::ConfigSetUpstream, results::SetUpstreamResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetLevelParams {
pub filter: String,
}
control_call!(SetLevelParams => ControlMethod::LogSetLevel, results::SetLevelResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetCapParams {
pub cap_bytes: u64,
}
control_call!(SetCapParams => ControlMethod::CacheSetCap, results::SetCapResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PinParams {
pub store: String,
}
control_call!(PinParams => ControlMethod::HostedStoresPin, results::PinResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnpinParams {
pub store: String,
}
control_call!(UnpinParams => ControlMethod::HostedStoresUnpin, results::UnpinResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostedStoreStatusParams {
pub store: String,
}
control_call!(HostedStoreStatusParams => ControlMethod::HostedStoresStatus, results::HostedStoreStatusResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapsuleFetchParams {
pub store: String,
pub root: String,
}
control_call!(CapsuleFetchParams => ControlMethod::CapsuleFetch, results::CapsuleFetchResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyncTriggerParams {
pub store: String,
}
control_call!(SyncTriggerParams => ControlMethod::SyncTrigger, results::SyncTriggerResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetChannelParams {
pub channel: String,
}
control_call!(SetChannelParams => ControlMethod::UpdaterSetChannel, serde_json::Value);
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PauseParams {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub until: Option<u64>,
}
control_call!(PauseParams => ControlMethod::UpdaterPause, serde_json::Value);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApproveParams {
pub pairing_id: String,
}
control_call!(ApproveParams => ControlMethod::PairingApprove, results::PairingApproveResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RevokeParams {
pub token_id: String,
}
control_call!(RevokeParams => ControlMethod::PairingRevoke, results::PairingRevokeResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeersConnectParams {
pub peer: String,
}
control_call!(PeersConnectParams => ControlMethod::PeersConnect, results::PeersConnectResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeersDisconnectParams {
pub peer: String,
}
control_call!(PeersDisconnectParams => ControlMethod::PeersDisconnect, results::PeersDisconnectResult);
pub fn canonical_peer_ip(raw: &str) -> Result<String, ControlError> {
raw.trim()
.parse::<std::net::IpAddr>()
.map(|ip| ip.to_string())
.map_err(|_| {
ControlError::of(
ControlErrorCode::InvalidParams,
format!(
"ip must be a bare IPv4 or IPv6 literal (no brackets, no port, no hostname), got: {raw:?}"
),
)
})
}
pub fn chia_peer_endpoint(ip: &str, port: u16) -> String {
match ip.trim().parse::<std::net::IpAddr>() {
Ok(std::net::IpAddr::V6(v6)) => format!("[{v6}]:{port}"),
Ok(std::net::IpAddr::V4(v4)) => format!("{v4}:{port}"),
Err(_) => format!("{ip}:{port}"),
}
}
pub const MAX_BANNED_CHIA_PEERS: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChiaPeersAddParams {
pub ip: String,
}
control_call!(ChiaPeersAddParams => ControlMethod::ChiaPeersAdd, results::ChiaPeersAddResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChiaPeersRemoveParams {
pub ip: String,
#[serde(default)]
pub ban: bool,
}
control_call!(ChiaPeersRemoveParams => ControlMethod::ChiaPeersRemove, results::ChiaPeersRemoveResult);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SubscriptionKind {
#[default]
Capsule,
Profile,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubscribeParams {
pub store_id: String,
#[serde(default)]
pub kind: SubscriptionKind,
}
control_call!(SubscribeParams => ControlMethod::Subscribe, results::SubscribeResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnsubscribeParams {
pub store_id: String,
}
control_call!(UnsubscribeParams => ControlMethod::Unsubscribe, results::UnsubscribeResult);
pub const ASSET_ID_HEX_LEN: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct AssetId([u8; 32]);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssetIdParseError {
WrongLength {
got: usize,
},
NotHex,
}
impl core::fmt::Display for AssetIdParseError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::WrongLength { got } => write!(
f,
"asset id must be {ASSET_ID_HEX_LEN} hex characters, got {got}"
),
Self::NotHex => f.write_str("asset id contains a non-hexadecimal character"),
}
}
}
impl std::error::Error for AssetIdParseError {}
impl AssetId {
pub const fn new(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn from_hex(hex: &str) -> Result<Self, AssetIdParseError> {
let body = hex.strip_prefix("0x").unwrap_or(hex);
if body.len() != ASSET_ID_HEX_LEN {
return Err(AssetIdParseError::WrongLength { got: body.len() });
}
let mut bytes = [0u8; 32];
for (byte, pair) in bytes.iter_mut().zip(body.as_bytes().chunks_exact(2)) {
let hi = decode_hex_digit(pair[0])?;
let lo = decode_hex_digit(pair[1])?;
*byte = (hi << 4) | lo;
}
Ok(Self(bytes))
}
pub fn to_hex(&self) -> String {
use core::fmt::Write as _;
self.0
.iter()
.fold(String::with_capacity(ASSET_ID_HEX_LEN), |mut acc, byte| {
let _ = write!(acc, "{byte:02x}");
acc
})
}
}
fn decode_hex_digit(c: u8) -> Result<u8, AssetIdParseError> {
match c {
b'0'..=b'9' => Ok(c - b'0'),
b'a'..=b'f' => Ok(c - b'a' + 10),
b'A'..=b'F' => Ok(c - b'A' + 10),
_ => Err(AssetIdParseError::NotHex),
}
}
impl core::fmt::Display for AssetId {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.to_hex())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Asset {
Xch,
Cat(AssetId),
}
const XCH_TOKEN: &str = "xch";
const DIG_TOKEN: &str = "dig";
impl Asset {
pub const DIG_ASSET_ID_HEX: &'static str =
"a406d3a9de984d03c9591c10d917593b434d5263cabe2b42f6b367df16832f81";
pub const DIG: Asset = Asset::Cat(AssetId::new([
0xa4, 0x06, 0xd3, 0xa9, 0xde, 0x98, 0x4d, 0x03, 0xc9, 0x59, 0x1c, 0x10, 0xd9, 0x17, 0x59,
0x3b, 0x43, 0x4d, 0x52, 0x63, 0xca, 0xbe, 0x2b, 0x42, 0xf6, 0xb3, 0x67, 0xdf, 0x16, 0x83,
0x2f, 0x81,
]));
pub fn is_dig(&self) -> bool {
*self == Self::DIG
}
pub fn asset_id(&self) -> Option<&AssetId> {
match self {
Self::Xch => None,
Self::Cat(id) => Some(id),
}
}
}
impl Serialize for Asset {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Xch => serializer.serialize_str(XCH_TOKEN),
other if other.is_dig() => serializer.serialize_str(DIG_TOKEN),
Self::Cat(id) => {
use serde::ser::SerializeMap as _;
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_entry("cat", &id.to_hex())?;
map.end()
}
}
}
}
impl<'de> Deserialize<'de> for Asset {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged, deny_unknown_fields)]
enum Wire {
Token(String),
Tagged { cat: String },
}
match Wire::deserialize(deserializer).map_err(|_| {
serde::de::Error::custom(
"expected \"xch\", \"dig\", or {\"cat\":\"<64-hex asset id>\"}",
)
})? {
Wire::Token(token) if token == XCH_TOKEN => Ok(Self::Xch),
Wire::Token(token) if token == DIG_TOKEN => Ok(Self::DIG),
Wire::Token(token) => Err(serde::de::Error::custom(format!(
"unknown asset {token:?}: expected \"xch\", \"dig\", or {{\"cat\":\"<64-hex asset id>\"}}"
))),
Wire::Tagged { cat } => AssetId::from_hex(&cat)
.map(Self::Cat)
.map_err(serde::de::Error::custom),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletBalanceParams {
pub address: String,
pub asset: Asset,
}
control_call!(WalletBalanceParams => ControlMethod::WalletBalance, results::WalletBalanceResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletCoinsParams {
pub address: String,
pub asset: Asset,
}
control_call!(WalletCoinsParams => ControlMethod::WalletCoins, results::WalletCoinsResult);
const COIN_ID_HEX_LEN: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WalletCoinByIdParams {
pub coin_id: String,
}
control_call!(WalletCoinByIdParams => ControlMethod::WalletCoinById, results::WalletCoinByIdResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WalletCoinSpendParams {
pub coin_id: String,
}
control_call!(WalletCoinSpendParams => ControlMethod::WalletCoinSpend, results::WalletCoinSpendResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WalletCoinsByParentParams {
pub parent_coin_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_coin_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
control_call!(WalletCoinsByParentParams => ControlMethod::WalletCoinsByParent, results::WalletCoinsByParentResult);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct WalletArrivalsParams {
#[serde(default)]
pub after_seq: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
control_call!(WalletArrivalsParams => ControlMethod::WalletArrivals, results::WalletArrivalsResult);
fn normalize_coin_id(coin_id: &str) -> Option<&str> {
let normalized = coin_id.strip_prefix("0x").unwrap_or(coin_id);
let well_formed = normalized.len() == COIN_ID_HEX_LEN
&& normalized
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
well_formed.then_some(normalized)
}
macro_rules! coin_id_params {
($ty:ident, $field:ident, $raw:ident, $error:expr) => {
impl<'de> Deserialize<'de> for $ty {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct $raw {
$field: String,
}
let raw = $raw::deserialize(deserializer)?;
let $field = normalize_coin_id(&raw.$field)
.ok_or_else(|| serde::de::Error::custom($error))?
.to_owned();
Ok(Self { $field })
}
}
impl $ty {
pub fn validated(self) -> Result<Self, crate::error::ControlError> {
let normalized = normalize_coin_id(&self.$field).ok_or_else(|| {
crate::error::ControlError::of(
crate::error::ControlErrorCode::InvalidParams,
$error,
)
})?;
Ok($ty {
$field: normalized.to_owned(),
})
}
}
};
}
const COIN_ID_ERROR: &str = "coin_id must be lowercase 64-hex, optionally 0x-prefixed";
const PARENT_COIN_ID_ERROR: &str =
"parent_coin_id must be lowercase 64-hex, optionally 0x-prefixed";
const AFTER_COIN_ID_ERROR: &str = "after_coin_id must be lowercase 64-hex, optionally 0x-prefixed";
coin_id_params!(
WalletCoinByIdParams,
coin_id,
RawWalletCoinByIdParams,
COIN_ID_ERROR
);
coin_id_params!(
WalletCoinSpendParams,
coin_id,
RawWalletCoinSpendParams,
COIN_ID_ERROR
);
pub const COINS_BY_PARENT_DEFAULT_LIMIT: u32 = 100;
pub const COINS_BY_PARENT_MAX_LIMIT: u32 = 1_000;
const COINS_BY_PARENT_LIMIT_ERROR: &str = "limit must be between 1 and 1000";
impl<'de> Deserialize<'de> for WalletCoinsByParentParams {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct RawWalletCoinsByParentParams {
parent_coin_id: String,
#[serde(default)]
after_coin_id: Option<String>,
#[serde(default)]
limit: Option<u32>,
}
let raw = RawWalletCoinsByParentParams::deserialize(deserializer)?;
let parent_coin_id = normalize_coin_id(&raw.parent_coin_id)
.ok_or_else(|| serde::de::Error::custom(PARENT_COIN_ID_ERROR))?
.to_owned();
let after_coin_id = raw
.after_coin_id
.map(|id| {
normalize_coin_id(&id)
.map(str::to_owned)
.ok_or_else(|| serde::de::Error::custom(AFTER_COIN_ID_ERROR))
})
.transpose()?;
if !raw.limit.map_or(true, is_legal_page) {
return Err(serde::de::Error::custom(COINS_BY_PARENT_LIMIT_ERROR));
}
Ok(Self {
parent_coin_id,
after_coin_id,
limit: raw.limit,
})
}
}
fn is_legal_page(limit: u32) -> bool {
(1..=COINS_BY_PARENT_MAX_LIMIT).contains(&limit)
}
impl WalletCoinsByParentParams {
pub fn first_page(parent_coin_id: impl Into<String>) -> Self {
Self {
parent_coin_id: parent_coin_id.into(),
after_coin_id: None,
limit: None,
}
}
pub fn effective_limit(&self) -> u32 {
self.limit.unwrap_or(COINS_BY_PARENT_DEFAULT_LIMIT)
}
pub fn validated(self) -> Result<Self, crate::error::ControlError> {
fn invalid(message: &'static str) -> crate::error::ControlError {
crate::error::ControlError::of(crate::error::ControlErrorCode::InvalidParams, message)
}
let parent_coin_id = normalize_coin_id(&self.parent_coin_id)
.ok_or_else(|| invalid(PARENT_COIN_ID_ERROR))?
.to_owned();
let after_coin_id = self
.after_coin_id
.as_deref()
.map(|id| {
normalize_coin_id(id)
.map(str::to_owned)
.ok_or_else(|| invalid(AFTER_COIN_ID_ERROR))
})
.transpose()?;
if !self.limit.map_or(true, is_legal_page) {
return Err(invalid(COINS_BY_PARENT_LIMIT_ERROR));
}
Ok(WalletCoinsByParentParams {
parent_coin_id,
after_coin_id,
limit: self.limit,
})
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletPeakParams {}
control_call!(WalletPeakParams => ControlMethod::WalletPeak, results::WalletPeakResult);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerCountsParams {}
control_call!(PeerCountsParams => ControlMethod::PeerCounts, results::PeerCountsResult);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletSyncStatusParams {}
control_call!(WalletSyncStatusParams => ControlMethod::WalletSyncStatus, results::WalletSyncStatusResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletBroadcastParams {
pub signed_bundle_hex: String,
}
control_call!(WalletBroadcastParams => ControlMethod::WalletBroadcast, results::WalletBroadcastResult);
const PUBLIC_KEY_HEX_LEN: usize = 96;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WalletWatchParams {
pub public_keys: Vec<String>,
}
control_call!(WalletWatchParams => ControlMethod::WalletWatch, results::WalletWatchResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WalletUnwatchParams {
pub public_keys: Vec<String>,
}
control_call!(WalletUnwatchParams => ControlMethod::WalletUnwatch, results::WalletUnwatchResult);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletWatchedParams {}
control_call!(WalletWatchedParams => ControlMethod::WalletWatched, results::WalletWatchedResult);
fn normalize_public_key(public_key: &str) -> Option<&str> {
let normalized = public_key.strip_prefix("0x").unwrap_or(public_key);
let well_formed = normalized.len() == PUBLIC_KEY_HEX_LEN
&& normalized
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
well_formed.then_some(normalized)
}
macro_rules! public_keys_params {
($ty:ident, $raw:ident, $error:expr) => {
impl<'de> Deserialize<'de> for $ty {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct $raw {
public_keys: Vec<String>,
}
let raw = $raw::deserialize(deserializer)?;
let public_keys = raw
.public_keys
.iter()
.map(|key| {
normalize_public_key(key)
.map(str::to_owned)
.ok_or_else(|| serde::de::Error::custom($error))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(Self { public_keys })
}
}
impl $ty {
pub fn validated(self) -> Result<Self, crate::error::ControlError> {
let public_keys = self
.public_keys
.iter()
.map(|key| {
normalize_public_key(key).map(str::to_owned).ok_or_else(|| {
crate::error::ControlError::of(
crate::error::ControlErrorCode::InvalidParams,
$error,
)
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(Self { public_keys })
}
}
};
}
const PUBLIC_KEYS_ERROR: &str =
"public_keys must each be lowercase 96-hex (a 48-byte G1 key), optionally 0x-prefixed";
public_keys_params!(WalletWatchParams, RawWalletWatch, PUBLIC_KEYS_ERROR);
public_keys_params!(WalletUnwatchParams, RawWalletUnwatch, PUBLIC_KEYS_ERROR);
const COIN_IDS_ERROR: &str =
"coin_ids must each be lowercase 64-hex (a 32-byte coin id), optionally 0x-prefixed";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletReservationsHeldParams {}
control_call!(WalletReservationsHeldParams => ControlMethod::WalletReservationsHeld, results::WalletReservationsHeldResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WalletReservationsReserveParams {
pub coin_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl_secs: Option<u64>,
}
control_call!(WalletReservationsReserveParams => ControlMethod::WalletReservationsReserve, results::WalletReservationsReserveResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WalletReservationsReleaseParams {
pub reservation_id: String,
}
control_call!(WalletReservationsReleaseParams => ControlMethod::WalletReservationsRelease, results::WalletReservationsReleaseResult);
impl<'de> Deserialize<'de> for WalletReservationsReserveParams {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct RawReserve {
coin_ids: Vec<String>,
#[serde(default)]
ttl_secs: Option<u64>,
}
let raw = RawReserve::deserialize(deserializer)?;
let coin_ids = raw
.coin_ids
.iter()
.map(|id| {
normalize_coin_id(id)
.map(str::to_owned)
.ok_or_else(|| serde::de::Error::custom(COIN_IDS_ERROR))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(Self {
coin_ids,
ttl_secs: raw.ttl_secs,
})
}
}
impl WalletReservationsReserveParams {
pub fn validated(self) -> Result<Self, ControlError> {
let coin_ids = self
.coin_ids
.iter()
.map(|id| {
normalize_coin_id(id).map(str::to_owned).ok_or_else(|| {
ControlError::of(ControlErrorCode::InvalidParams, COIN_IDS_ERROR)
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(Self {
coin_ids,
ttl_secs: self.ttl_secs,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RequestParams {
pub client_name: String,
}
control_call!(RequestParams => ControlMethod::PairingRequest, results::PairingRequestResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PollParams {
pub pairing_id: String,
}
control_call!(PollParams => ControlMethod::PairingPoll, results::PairingPollResult);
pub const MAX_BODY_BYTES: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfilePutBodyParams {
pub store_id: String,
pub root: String,
pub body_b64: String,
}
control_call!(ProfilePutBodyParams => ControlMethod::ProfilePutBody, results::ProfilePutBodyResult);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileGetBodyParams {
pub store_id: String,
pub root: String,
}
control_call!(ProfileGetBodyParams => ControlMethod::ProfileGetBody, results::ProfileGetBodyResult);
pub const SPENDS_LIST_DEFAULT_LIMIT: u32 = 50;
pub const SPENDS_LIST_MAX_LIMIT: u32 = 500;
const SPENDS_LIST_LIMIT_ERROR: &str = "limit must be between 1 and 500 spends per page";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct SpendsListParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub since_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub until_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub store_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
impl SpendsListParams {
pub fn effective_limit(&self) -> u32 {
self.limit.unwrap_or(SPENDS_LIST_DEFAULT_LIMIT)
}
pub fn validated(self) -> Result<Self, ControlError> {
if let Some(limit) = self.limit {
if limit == 0 || limit > SPENDS_LIST_MAX_LIMIT {
return Err(ControlError::of(
ControlErrorCode::InvalidParams,
SPENDS_LIST_LIMIT_ERROR,
));
}
}
Ok(self)
}
}
impl<'de> Deserialize<'de> for SpendsListParams {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Raw {
#[serde(default)]
since_ms: Option<u64>,
#[serde(default)]
until_ms: Option<u64>,
#[serde(default)]
store_id: Option<String>,
#[serde(default)]
kind: Option<String>,
#[serde(default)]
status: Option<String>,
#[serde(default)]
after_id: Option<String>,
#[serde(default)]
limit: Option<u32>,
}
let raw = Raw::deserialize(deserializer)?;
SpendsListParams {
since_ms: raw.since_ms,
until_ms: raw.until_ms,
store_id: raw.store_id,
kind: raw.kind,
status: raw.status,
after_id: raw.after_id,
limit: raw.limit,
}
.validated()
.map_err(serde::de::Error::custom)
}
}
control_call!(SpendsListParams => ControlMethod::SpendsList, results::SpendsListResult);
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::build_request;
use serde_json::json;
const OTHER_CAT_HEX: &str = "1c2b3a4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809";
#[test]
fn legacy_xch_and_dig_spellings_still_deserialize() {
assert_eq!(
serde_json::from_value::<Asset>(json!("xch")).unwrap(),
Asset::Xch
);
assert_eq!(
serde_json::from_value::<Asset>(json!("dig")).unwrap(),
Asset::DIG
);
}
#[test]
fn dig_still_serializes_to_its_legacy_token() {
assert_eq!(serde_json::to_value(Asset::DIG).unwrap(), json!("dig"));
assert_eq!(serde_json::to_value(Asset::Xch).unwrap(), json!("xch"));
}
#[test]
fn an_arbitrary_cat_round_trips_by_asset_id() {
let asset = Asset::Cat(AssetId::from_hex(OTHER_CAT_HEX).unwrap());
let wire = serde_json::to_value(asset).unwrap();
assert_eq!(wire, json!({ "cat": OTHER_CAT_HEX }));
assert_eq!(serde_json::from_value::<Asset>(wire).unwrap(), asset);
}
#[test]
fn dig_spelled_either_way_is_one_and_the_same_value() {
let via_token: Asset = serde_json::from_value(json!("dig")).unwrap();
let via_asset_id: Asset =
serde_json::from_value(json!({ "cat": Asset::DIG_ASSET_ID_HEX })).unwrap();
assert_eq!(via_token, via_asset_id);
assert_eq!(via_token, Asset::DIG);
assert!(via_asset_id.is_dig());
}
#[test]
fn asset_id_hex_length_is_bounded_on_both_sides() {
assert!(AssetId::from_hex(&"a".repeat(63)).is_err());
assert!(AssetId::from_hex(&"a".repeat(64)).is_ok());
assert!(AssetId::from_hex(&"a".repeat(65)).is_err());
}
#[test]
fn asset_id_normalizes_prefix_and_case_but_emits_lowercase_unprefixed() {
let upper = OTHER_CAT_HEX.to_uppercase();
let canonical = AssetId::from_hex(OTHER_CAT_HEX).unwrap();
assert_eq!(AssetId::from_hex(&upper).unwrap(), canonical);
assert_eq!(AssetId::from_hex(&format!("0x{upper}")).unwrap(), canonical);
assert_eq!(canonical.to_hex(), OTHER_CAT_HEX);
}
#[test]
fn dig_asset_id_hex_and_bytes_are_the_same_id() {
let from_hex = AssetId::from_hex(Asset::DIG_ASSET_ID_HEX).unwrap();
assert_eq!(Asset::DIG.asset_id(), Some(&from_hex));
assert_eq!(from_hex.to_hex(), Asset::DIG_ASSET_ID_HEX);
assert_eq!(Asset::Xch.asset_id(), None);
}
#[test]
fn parse_errors_name_the_defect_and_reach_the_wire() {
assert_eq!(
AssetId::from_hex("ab").unwrap_err(),
AssetIdParseError::WrongLength { got: 2 }
);
assert!(AssetIdParseError::WrongLength { got: 2 }
.to_string()
.contains("64 hex characters"));
assert!(AssetIdParseError::NotHex
.to_string()
.contains("non-hexadecimal"));
assert!(AssetId::from_hex(&"3c".repeat(32))
.unwrap()
.to_string()
.starts_with("3c3c"));
let err = serde_json::from_value::<Asset>(json!({ "cat": "zz" })).unwrap_err();
assert!(err.to_string().contains("64 hex characters"), "{err}");
}
#[test]
fn malformed_assets_are_rejected_rather_than_guessed_at() {
assert!(AssetId::from_hex(&"z".repeat(64)).is_err());
assert!(serde_json::from_value::<Asset>(json!("usdc")).is_err());
assert!(serde_json::from_value::<Asset>(json!({})).is_err());
assert!(serde_json::from_value::<Asset>(json!({ "tail": OTHER_CAT_HEX })).is_err());
assert!(serde_json::from_value::<Asset>(json!({ "cat": "ab" })).is_err());
}
#[test]
fn balance_and_coins_reads_can_name_an_arbitrary_cat() {
let cat = Asset::Cat(AssetId::from_hex(OTHER_CAT_HEX).unwrap());
assert_eq!(
serde_json::to_value(WalletBalanceParams {
address: "xch1exampleaddr".into(),
asset: cat,
})
.unwrap(),
json!({ "address": "xch1exampleaddr", "asset": { "cat": OTHER_CAT_HEX } })
);
assert_eq!(
serde_json::to_value(WalletCoinsParams {
address: "xch1exampleaddr".into(),
asset: cat,
})
.unwrap(),
json!({ "address": "xch1exampleaddr", "asset": { "cat": OTHER_CAT_HEX } })
);
}
#[test]
fn no_param_call_serializes_params_to_empty_object() {
let req = build_request(1.into(), &StatusParams {});
assert_eq!(req.method, "control.status");
assert_eq!(req.params, json!({}));
}
#[test]
fn data_param_call_carries_its_fields() {
let req = build_request(2.into(), &SetCapParams { cap_bytes: 128 });
assert_eq!(req.method, "control.cache.setCap");
assert_eq!(req.params, json!({ "cap_bytes": 128 }));
}
#[test]
fn pause_omits_until_when_indefinite() {
assert_eq!(
serde_json::to_value(PauseParams { until: None }).unwrap(),
json!({})
);
assert_eq!(
serde_json::to_value(PauseParams { until: Some(99) }).unwrap(),
json!({ "until": 99 })
);
}
#[test]
fn method_binding_matches_the_catalog_name() {
assert_eq!(
SetUpstreamParams::METHOD.name(),
"control.config.setUpstream"
);
assert_eq!(RequestParams::METHOD.name(), "pairing.request");
assert_eq!(PollParams::METHOD.name(), "pairing.poll");
}
#[test]
fn spellings_of_the_same_peer_canonicalise_to_one_key() {
for (typed, also_typed) in [
("2001:0db8:0000:0000:0000:0000:0000:0001", "2001:DB8::1"),
("::1", "0:0:0:0:0:0:0:1"),
(" 203.0.113.7 ", "203.0.113.7"),
] {
let a = canonical_peer_ip(typed).expect("typed form is a literal");
let b = canonical_peer_ip(also_typed).expect("second form is a literal");
assert_eq!(a, b, "{typed} and {also_typed} name one peer");
}
assert_eq!(canonical_peer_ip("2001:DB8:0:0::1").unwrap(), "2001:db8::1");
}
#[test]
fn a_peer_ip_that_is_not_a_bare_literal_is_refused() {
for bad in [
"",
" ",
"node.example.com",
"[2001:db8::1]",
"[2001:db8::1]:8444",
"203.0.113.7:8444",
"203.0.113.0/24",
"not an address",
] {
let Err(err) = canonical_peer_ip(bad) else {
panic!("{bad:?} must be refused, it was accepted");
};
assert_eq!(
err.code_enum(),
Some(ControlErrorCode::InvalidParams),
"{bad:?} must be an INVALID_PARAMS refusal, not some other failure"
);
}
}
#[test]
fn joining_a_v6_peer_to_a_port_cannot_produce_a_different_address() {
assert_eq!(chia_peer_endpoint("::1", 8444), "[::1]:8444");
assert_eq!(
chia_peer_endpoint("2001:db8::1", 8444),
"[2001:db8::1]:8444"
);
assert_eq!(chia_peer_endpoint("203.0.113.7", 8444), "203.0.113.7:8444");
let naive = format!("{}:{}", "::1", 8444);
let hijacked: std::net::IpAddr =
naive.parse().expect("the naive join is itself an address");
assert_ne!(
hijacked,
"::1".parse::<std::net::IpAddr>().unwrap(),
"the naive join must be a DIFFERENT address — that is why the helper exists"
);
assert_ne!(chia_peer_endpoint("::1", 8444), naive);
}
}
pub const DEFAULT_SAFETY_MARGIN_BP: u64 = 100;
pub const MAX_SAFETY_MARGIN_BP: u64 = 10_000;
no_params!(
CollateralRequirementParams => ControlMethod::CollateralRequirement,
results::CollateralRequirementResult
);
no_params!(
CollateralMarginGetParams => ControlMethod::CollateralMarginGet,
results::CollateralMarginResult
);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CollateralMarginSetParams {
pub margin_bp: u64,
}
impl CollateralMarginSetParams {
pub fn validated(self) -> Result<Self, ControlError> {
if self.margin_bp > MAX_SAFETY_MARGIN_BP {
return Err(ControlError::of(
ControlErrorCode::InvalidParams,
format!(
"margin_bp must be at most {MAX_SAFETY_MARGIN_BP} basis points (+100%); got {}",
self.margin_bp
),
));
}
Ok(self)
}
}
control_call!(CollateralMarginSetParams => ControlMethod::CollateralMarginSet, results::CollateralMarginResult);