add query to check for feegrant inclusion

This commit is contained in:
Jędrzej Stuczyński
2026-06-05 15:07:08 +01:00
parent 4e5dfcbfeb
commit 7a7a14f585
8 changed files with 217 additions and 169 deletions
@@ -228,117 +228,3 @@ impl From<NymNodeDescriptionV1> for NymNodeDescriptionV2 {
}
}
}
#[cfg(any(test, feature = "mock-fixtures"))]
pub fn mock_nym_node_description(seed: u64) -> NymNodeDescriptionV2 {
use nym_node_requests::api::v1::lewes_protocol::models::{LPHashFunction, LPKEM};
use nym_test_utils::helpers::{u64_seeded_rng, RngCore};
let mut rng = u64_seeded_rng(seed);
let ed25519 = nym_crypto::asymmetric::ed25519::KeyPair::new(&mut rng);
// just reuse the same x25519 key for everything - this is just a data mock
let x25519 = nym_crypto::asymmetric::x25519::KeyPair::new(&mut rng);
let mut dummy_kems = std::collections::BTreeMap::new();
for kem in [LPKEM::McEliece, LPKEM::McEliece] {
let mut kem_digests = std::collections::BTreeMap::new();
for (i, sf) in [
LPHashFunction::Blake3,
LPHashFunction::Shake128,
LPHashFunction::Shake256,
LPHashFunction::Sha256,
]
.iter()
.enumerate()
{
kem_digests.insert(*sf, hex::encode([((seed + i as u64) % 256) as u8; 32]));
}
dummy_kems.insert(kem, kem_digests);
}
// make sure the serialisation stays the same and signature is still valid
let dummy_lp = nym_node_requests::api::v1::lewes_protocol::models::LewesProtocol {
enabled: false,
control_port: 123,
data_port: 345,
x25519: (*x25519.public_key()).into(),
kem_keys: dummy_kems,
};
let dummy_signed_lp =
nym_node_requests::api::SignedLewesProtocol::new(dummy_lp, ed25519.private_key()).unwrap();
NymNodeDescriptionV2 {
node_id: rng.next_u32(),
contract_node_type: DescribedNodeTypeV1::NymNode,
description: NymNodeDataV2 {
last_polled: time::OffsetDateTime::from_unix_timestamp(1767225600)
.unwrap()
.into(),
host_information: HostInformationV2 {
ip_address: vec![
std::net::IpAddr::V4(std::net::Ipv4Addr::new(1, 2, 3, (seed % 255) as u8)),
],
hostname: Some(format!("my-awesome-node-{seed}.com")),
keys: HostKeysV2 {
ed25519: *ed25519.public_key(),
x25519: *x25519.public_key(),
current_x25519_sphinx_key: SphinxKeyV2 {
rotation_id: 123,
public_key: *x25519.public_key(),
},
pre_announced_x25519_sphinx_key: None,
x25519_versioned_noise: Some(VersionedNoiseKeyV2 {
supported_version: nym_noise_keys::NoiseVersion::V1,
x25519_pubkey: *x25519.public_key(),
}),
},
},
declared_role: DeclaredRolesV2 {
mixnode: false,
entry: true,
exit_nr: true,
exit_ipr: true,
},
auxiliary_details: NymNodeAuxiliaryDetailsV2 {
location: Some(celes::Country::switzerland()),
announce_ports: Default::default(),
accepted_operator_terms_and_conditions: true,
},
build_information: BinaryBuildInformationOwned {
binary_name: "dummy-node".to_string(),
build_timestamp: "2021-02-23T20:14:46.558472672+00:00".to_string(),
build_version: "0.1.0-9-g46f83e1".to_string(),
commit_sha: "46f83e112520533338245862d366f6a02cef07d4".to_string(),
commit_timestamp: "2021-02-23T08:08:02-05:00".to_string(),
commit_branch: "master".to_string(),
rustc_version: "1.52.0-nightly".to_string(),
rustc_channel: "nightly".to_string(),
cargo_profile: "release".to_string(),
cargo_triple: "wasm32-unknown-unknown".to_string(),
},
network_requester: Some(NetworkRequesterDetailsV2 {
address: "FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve".to_string(),
uses_exit_policy: true,
}),
ip_packet_router: Some(IpPacketRouterDetailsV2 {
address: "FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve".to_string(),
}),
authenticator: Some(AuthenticatorDetailsV2 {
address: "FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve".to_string(),
}),
wireguard: Some(WireguardDetailsV2 {
port: 123,
tunnel_port: 234,
metadata_port: 456,
public_key: x25519.public_key().to_base58_string(),
}),
lewes_protocol: Some(dummy_signed_lp.into()),
mixnet_websockets: WebSocketsV2 {
ws_port: 9000,
wss_port: None,
},
},
}
}
@@ -279,3 +279,118 @@ impl From<NymNodeAuxiliaryDetailsV3> for NymNodeAuxiliaryDetailsV2 {
}
}
}
#[cfg(any(test, feature = "mock-fixtures"))]
pub fn mock_nym_node_description(seed: u64) -> NymNodeDescriptionV3 {
use nym_node_requests::api::v1::lewes_protocol::models::{LPHashFunction, LPKEM};
use nym_test_utils::helpers::{u64_seeded_rng, RngCore};
let mut rng = u64_seeded_rng(seed);
let ed25519 = nym_crypto::asymmetric::ed25519::KeyPair::new(&mut rng);
// just reuse the same x25519 key for everything - this is just a data mock
let x25519 = nym_crypto::asymmetric::x25519::KeyPair::new(&mut rng);
let mut dummy_kems = std::collections::BTreeMap::new();
for kem in [LPKEM::McEliece, LPKEM::McEliece] {
let mut kem_digests = std::collections::BTreeMap::new();
for (i, sf) in [
LPHashFunction::Blake3,
LPHashFunction::Shake128,
LPHashFunction::Shake256,
LPHashFunction::Sha256,
]
.iter()
.enumerate()
{
kem_digests.insert(*sf, hex::encode([((seed + i as u64) % 256) as u8; 32]));
}
dummy_kems.insert(kem, kem_digests);
}
// make sure the serialisation stays the same and signature is still valid
let dummy_lp = nym_node_requests::api::v1::lewes_protocol::models::LewesProtocol {
enabled: false,
control_port: 123,
data_port: 345,
x25519: (*x25519.public_key()).into(),
kem_keys: dummy_kems,
};
let dummy_signed_lp =
nym_node_requests::api::SignedLewesProtocol::new(dummy_lp, ed25519.private_key()).unwrap();
NymNodeDescriptionV3 {
node_id: rng.next_u32(),
contract_node_type: DescribedNodeTypeV3::NymNode,
description: NymNodeDataV3 {
last_polled: time::OffsetDateTime::from_unix_timestamp(1767225600)
.unwrap()
.into(),
host_information: HostInformationV3 {
ip_address: vec![
std::net::IpAddr::V4(std::net::Ipv4Addr::new(1, 2, 3, (seed % 255) as u8)),
],
hostname: Some(format!("my-awesome-node-{seed}.com")),
keys: HostKeysV3 {
ed25519: *ed25519.public_key(),
x25519: *x25519.public_key(),
current_x25519_sphinx_key: SphinxKeyV3 {
rotation_id: 123,
public_key: *x25519.public_key(),
},
pre_announced_x25519_sphinx_key: None,
x25519_versioned_noise: Some(VersionedNoiseKeyV3 {
supported_version: nym_noise_keys::NoiseVersion::V1,
x25519_pubkey: *x25519.public_key(),
}),
},
},
declared_role: DeclaredRolesV3 {
mixnode: false,
entry: true,
exit_nr: true,
exit_ipr: true,
},
auxiliary_details: NymNodeAuxiliaryDetailsV3 {
location: Some(celes::Country::switzerland()),
address: Some("n1jw6mp7d5xqc7w6xm79lha27glmd0vdt3l9artf".to_string()),
announce_ports: Default::default(),
accepted_operator_terms_and_conditions: true,
},
build_information: BinaryBuildInformationOwned {
binary_name: "dummy-node".to_string(),
build_timestamp: "2021-02-23T20:14:46.558472672+00:00".to_string(),
build_version: "0.1.0-9-g46f83e1".to_string(),
commit_sha: "46f83e112520533338245862d366f6a02cef07d4".to_string(),
commit_timestamp: "2021-02-23T08:08:02-05:00".to_string(),
commit_branch: "master".to_string(),
rustc_version: "1.52.0-nightly".to_string(),
rustc_channel: "nightly".to_string(),
cargo_profile: "release".to_string(),
cargo_triple: "wasm32-unknown-unknown".to_string(),
},
network_requester: Some(NetworkRequesterDetailsV3 {
address: "FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve".to_string(),
uses_exit_policy: true,
}),
ip_packet_router: Some(IpPacketRouterDetailsV3 {
address: "FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve".to_string(),
}),
authenticator: Some(AuthenticatorDetailsV3 {
address: "FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve".to_string(),
}),
wireguard: Some(WireguardDetailsV3 {
port: 123,
tunnel_port: 234,
metadata_port: 456,
public_key: x25519.public_key().to_base58_string(),
}),
lewes_protocol: Some(dummy_signed_lp.into()),
mixnet_websockets: WebSocketsV3 {
ws_port: 9000,
wss_port: None,
},
},
}
}
@@ -419,12 +419,21 @@ pub struct NodeAnnotationV1 {
pub detailed_performance: DetailedNodePerformanceV1,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct ChainInteractionCapabilitiesDetailed {
#[schema(value_type = CoinSchema)]
pub on_chain_balance: Coin,
// later to be expanded with information on whether the grant would cover
// cosmwasm executemsg, but for now we assume any feegrant is sufficient
pub is_feegrant_grantee: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct NodeAnnotationV2 {
pub current_role: Option<DisplayRole>,
#[schema(value_type = Option<CoinSchema>)]
pub on_chain_balance: Option<Coin>,
pub chain_interaction_capabilities: Option<ChainInteractionCapabilitiesDetailed>,
pub detailed_performance: DetailedNodePerformanceV2,
}
+16 -11
View File
@@ -4,10 +4,11 @@
use crate::mixnet_contract_cache::cache::data::ConfigScoreData;
use cosmwasm_std::Coin;
use nym_api_requests::models::described::v3::NymNodeDescriptionV3;
use nym_api_requests::models::{ChainInteractionCapabilities, ConfigScoreV2};
use nym_api_requests::models::{
ChainInteractionCapabilities, ChainInteractionCapabilitiesDetailed, ConfigScoreV2,
};
use nym_contracts_common::NaiveFloat;
use nym_mixnet_contract_common::VersionScoreFormulaParams;
use tracing::warn;
fn versions_behind_factor_to_config_score(
versions_behind: u32,
@@ -20,10 +21,15 @@ fn versions_behind_factor_to_config_score(
penalty.powf((versions_behind as f64).powf(scaling))
}
fn has_sufficient_tokens(minimum_balance: &Coin, chain_balance: &Option<Coin>) -> bool {
let Some(chain_balance) = chain_balance else {
fn has_sufficient_tokens(
minimum_balance: &Coin,
capabilities: &Option<ChainInteractionCapabilitiesDetailed>,
) -> bool {
let Some(capabilities) = capabilities else {
return false;
};
let chain_balance = &capabilities.on_chain_balance;
if chain_balance.denom != minimum_balance.denom {
return false;
}
@@ -34,7 +40,7 @@ pub(crate) fn calculate_config_score(
minimum_balance: &Coin,
config_score_data: &ConfigScoreData,
described_data: Option<&NymNodeDescriptionV3>,
chain_balance: &Option<Coin>,
chain_capabilities: &Option<ChainInteractionCapabilitiesDetailed>,
) -> ConfigScoreV2 {
let Some(described) = described_data else {
return ConfigScoreV2::unavailable();
@@ -69,13 +75,12 @@ pub(crate) fn calculate_config_score(
)
};
let TODO = "";
warn!("unimplemented check for feegrant");
let chain_interaction = ChainInteractionCapabilities {
has_sufficient_tokens: has_sufficient_tokens(minimum_balance, chain_balance),
// TODO: implement this
is_fee_grant_grantee: false,
has_sufficient_tokens: has_sufficient_tokens(minimum_balance, chain_capabilities),
is_fee_grant_grantee: chain_capabilities
.as_ref()
.map(|c| c.is_feegrant_grantee)
.unwrap_or_default(),
};
ConfigScoreV2::new(
+7 -4
View File
@@ -5,7 +5,7 @@ use self::data::NodeStatusCacheData;
use crate::node_performance::provider::PerformanceRetrievalFailure;
use crate::support::caching::cache::{SharedCache, UninitialisedCache};
use crate::support::caching::Cache;
use nym_api_requests::models::NodeAnnotationV2;
use nym_api_requests::models::{ChainInteractionCapabilitiesDetailed, NodeAnnotationV2};
use nym_mixnet_contract_common::NodeId;
use std::collections::HashMap;
use std::path::Path;
@@ -104,15 +104,18 @@ impl NodeStatusCache {
self.get(|c| &c.node_annotations).await
}
async fn node_balances(
async fn chain_information(
&self,
) -> Result<HashMap<NodeId, Option<cosmwasm_std::Coin>>, NodeStatusCacheError> {
) -> Result<HashMap<NodeId, Option<ChainInteractionCapabilitiesDetailed>>, NodeStatusCacheError>
{
Ok(self
.cache()
.await?
.node_annotations
.iter()
.map(|(node_id, annotation)| (*node_id, annotation.on_chain_balance.clone()))
.map(|(node_id, annotation)| {
(*node_id, annotation.chain_interaction_capabilities.clone())
})
.collect::<HashMap<_, _>>())
}
}
+63 -33
View File
@@ -17,13 +17,16 @@ use crate::{
node_status_api::cache::NodeStatusCacheError, support::caching::CacheNotification,
};
use ::time::OffsetDateTime;
use cosmwasm_std::Coin;
use cosmwasm_std::{coin, Coin};
use futures::StreamExt;
use nym_api_requests::models::described::v3::NymNodeDescriptionV3;
use nym_api_requests::models::{DetailedNodePerformanceV2, NodeAnnotationV2};
use nym_api_requests::models::{
ChainInteractionCapabilitiesDetailed, DetailedNodePerformanceV2, NodeAnnotationV2,
};
use nym_mixnet_contract_common::{NodeId, NymNodeDetails};
use nym_task::ShutdownToken;
use nym_topology::CachedEpochRewardedSet;
use nym_validator_client::nyxd::module_traits::feegrant::query::FeegrantQueryClient;
use nym_validator_client::nyxd::{AccountId, CosmWasmClient};
use nym_validator_client::QueryHttpRpcNyxdClient;
use std::collections::HashMap;
@@ -38,9 +41,9 @@ pub(crate) struct NodeStatusCacheConfig {
pub(crate) minimum_on_chain_balance: Coin,
pub(crate) balance_retrieval_concurrency: usize,
/// Indicates how often should the chain balances of known nodes be refreshed.
/// Indicates how often should the chain balances (and feegrants) of known nodes be refreshed.
/// (it is an overkill to do it every single iteration)
pub(crate) chain_balances_refresh_interval: Duration,
pub(crate) chain_capabilities_refresh_interval: Duration,
pub(crate) fallback_caching_interval: Duration,
@@ -232,10 +235,11 @@ impl NodeStatusCacheRefresher {
// SAFETY: unwrap is fine as if the mutex got poisoned we'd be experiencing some UB anyway
#[allow(clippy::unwrap_used)]
async fn retrieve_balances(
async fn retrieve_chain_info(
&self,
nodes: &DescribedNodes,
) -> Result<HashMap<NodeId, Option<Coin>>, NodeStatusCacheError> {
) -> Result<HashMap<NodeId, Option<ChainInteractionCapabilitiesDetailed>>, NodeStatusCacheError>
{
let denom = self.config.minimum_on_chain_balance.denom.clone();
// create an iterator of node ids with valid associated account addresses
@@ -260,30 +264,24 @@ impl NodeStatusCacheRefresher {
let concurrency = self.config.balance_retrieval_concurrency.max(1);
// std Mutex is fine because we don't hold it across await points
let balances = std::sync::Mutex::new(HashMap::<NodeId, Option<Coin>>::new());
let capabilities = std::sync::Mutex::new(HashMap::<
NodeId,
Option<ChainInteractionCapabilitiesDetailed>,
>::new());
futures::stream::iter(to_check)
.for_each_concurrent(concurrency, |(node_id, account_id)| {
let denom = denom.clone();
let query_client = &self.query_client;
let balances = &balances;
let capabilities = &capabilities;
async move {
match query_client.get_balance(&account_id, denom).await {
Ok(balance) => {
balances
.lock()
.unwrap()
.insert(node_id, balance.map(Into::into));
}
Err(err) => {
warn!(node_id, %err, "failed to retrieve node balance");
}
}
let chain_info =
retrieve_chain_capabilities(query_client, node_id, account_id, denom).await;
capabilities.lock().unwrap().insert(node_id, chain_info);
}
})
.await;
let balances = balances.into_inner().unwrap();
Ok(balances)
Ok(capabilities.into_inner().unwrap())
}
#[allow(clippy::too_many_arguments)]
@@ -295,7 +293,7 @@ impl NodeStatusCacheRefresher {
nym_nodes: &[NymNodeDetails],
rewarded_set: &CachedEpochRewardedSet,
described_nodes: &DescribedNodes,
balances: HashMap<NodeId, Option<Coin>>,
chain_capabilities: HashMap<NodeId, Option<ChainInteractionCapabilitiesDetailed>>,
) -> HashMap<NodeId, NodeAnnotationV2> {
let mut annotations = HashMap::new();
if nym_nodes.is_empty() {
@@ -336,13 +334,13 @@ impl NodeStatusCacheRefresher {
let described = described_nodes.get_node(&node_id);
let routing_score = routing_scores.get_or_log(node_id);
let stress_testing_score = stress_testing_scores.get_or_log(node_id);
let on_chain_balance = balances.get(&node_id).unwrap_or(&None).clone();
let node_chain_cap = chain_capabilities.get(&node_id).unwrap_or(&None).clone();
let config_score = calculate_config_score(
minimum_balance,
config_score_data,
described,
&on_chain_balance,
&node_chain_cap,
);
// a node only takes the stress-testing component if it is actually stress-tested (i.e.
@@ -360,7 +358,7 @@ impl NodeStatusCacheRefresher {
node_id,
NodeAnnotationV2 {
current_role: rewarded_set.role(node_id).map(|r| r.into()),
on_chain_balance,
chain_interaction_capabilities: node_chain_cap,
detailed_performance: DetailedNodePerformanceV2::new(
performance,
routing_score,
@@ -374,11 +372,11 @@ impl NodeStatusCacheRefresher {
annotations
}
fn should_refresh_balances(&self) -> bool {
fn should_refresh_chain_interaction(&self) -> bool {
let Some(last_refresh) = self.last_refreshed_chain_balances else {
return true;
};
last_refresh.elapsed() > self.config.chain_balances_refresh_interval
last_refresh.elapsed() > self.config.chain_capabilities_refresh_interval
}
/// Refreshes the node status cache by fetching the latest data from the contract cache
@@ -417,13 +415,13 @@ impl NodeStatusCacheRefresher {
// decide whether to refresh cache of node balances
let balances = if self.should_refresh_balances() {
let balances = self.retrieve_balances(&described).await?;
let chain_info = if self.should_refresh_chain_interaction() {
let info = self.retrieve_chain_info(&described).await?;
self.last_refreshed_chain_balances = Some(Instant::now());
balances
info
} else {
// use the currently cached values instead
self.cache.node_balances().await?
self.cache.chain_information().await?
};
// Create annotated data
@@ -435,7 +433,7 @@ impl NodeStatusCacheRefresher {
&nym_nodes,
&rewarded_set,
&described,
balances,
chain_info,
)
.await;
@@ -454,6 +452,38 @@ impl NodeStatusCacheRefresher {
}
}
async fn retrieve_chain_capabilities(
query_client: &QueryHttpRpcNyxdClient,
node_id: NodeId,
account_id: AccountId,
balance_denom: String,
) -> Option<ChainInteractionCapabilitiesDetailed> {
let on_chain_balance = match query_client
.get_balance(&account_id, balance_denom.clone())
.await
{
Ok(balance) => balance.map(Into::into).unwrap_or(coin(0, balance_denom)),
Err(err) => {
warn!(node_id, %err, "failed to retrieve node balance");
return None;
}
};
let is_feegrant_grantee = match query_client.allowances(account_id, None).await {
Ok(allowances) => !allowances.allowances.is_empty(),
Err(err) => {
warn!(node_id, %err, "failed to retrieve node feegrant allowances");
// if there was a network blip, at least preserve the balance information
false
}
};
Some(ChainInteractionCapabilitiesDetailed {
on_chain_balance,
is_feegrant_grantee,
})
}
/// Whether `node` is currently in scope for stress testing, and therefore expected to have a
/// stress-test sample. This is the single source of truth for stress-test scope and must stay in
/// sync with the orchestrator's test-target selection (`NodeType::from_roles`, which keys off the
@@ -506,7 +536,7 @@ fn node_performance(
#[cfg(test)]
mod tests {
use super::*;
use nym_api_requests::models::mock_nym_node_description;
use nym_api_requests::models::described::v3::mock_nym_node_description;
#[test]
fn ineligible_nodes_are_not_penalised_for_missing_stress_data() {
+2 -2
View File
@@ -60,10 +60,10 @@ pub(crate) async fn start_cache_refresh(
.node_status_api
.debug
.node_balance_retrieval_concurrency,
chain_balances_refresh_interval: config
chain_capabilities_refresh_interval: config
.node_status_api
.debug
.chain_balance_refresh_interval,
.chain_capabilities_refresh_interval,
fallback_caching_interval: config.node_status_api.debug.caching_interval,
use_stress_testing_data: config.performance_provider.debug.use_stress_testing_data,
+3 -3
View File
@@ -728,13 +728,13 @@ pub struct NodeStatusAPIDebug {
pub node_balance_retrieval_concurrency: usize,
#[serde(with = "humantime_serde")]
pub chain_balance_refresh_interval: Duration,
pub chain_capabilities_refresh_interval: Duration,
}
impl NodeStatusAPIDebug {
const DEFAULT_NODE_STATUS_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(305);
const DEFAULT_NODE_BALANCE_RETRIEVAL_CONCURRENCY: usize = 8;
const DEFAULT_CHAIN_BALANCE_REFRESH_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); // once a day is more than enough
const DEFAULT_CHAIN_CAPABILITIES_REFRESH_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); // once a day is more than enough
const DEFAULT_CHAIN_BALANCE_REFRESH_THRESHOLD: u128 = 1_000000; // 1 nym is enough for all tx fees for quite some time
}
@@ -744,7 +744,7 @@ impl Default for NodeStatusAPIDebug {
caching_interval: Self::DEFAULT_NODE_STATUS_CACHE_REFRESH_INTERVAL,
minimum_on_chain_balance_amount: Self::DEFAULT_CHAIN_BALANCE_REFRESH_THRESHOLD,
node_balance_retrieval_concurrency: Self::DEFAULT_NODE_BALANCE_RETRIEVAL_CONCURRENCY,
chain_balance_refresh_interval: Self::DEFAULT_CHAIN_BALANCE_REFRESH_INTERVAL,
chain_capabilities_refresh_interval: Self::DEFAULT_CHAIN_CAPABILITIES_REFRESH_INTERVAL,
}
}
}