diff --git a/.gitignore b/.gitignore index 5646f38f53..6eba9a6ef2 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ nym-api/redocly/formatted-openapi.json *.sqlite .build + +**/settings.sql +**/enter_db.sh diff --git a/Cargo.lock b/Cargo.lock index bcf25449f7..a00d8a51c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4760,6 +4760,7 @@ dependencies = [ "humantime-serde", "itertools 0.14.0", "k256", + "moka", "nym-api-requests", "nym-bandwidth-controller", "nym-bin-common", diff --git a/nym-api/Cargo.toml b/nym-api/Cargo.toml index 2470440442..8426014596 100644 --- a/nym-api/Cargo.toml +++ b/nym-api/Cargo.toml @@ -27,6 +27,7 @@ humantime-serde = { workspace = true } k256 = { workspace = true, features = [ "ecdsa-core", ] } # needed for the Verifier trait; pull whatever version is used by other dependencies +moka = { workspace = true } pin-project = { workspace = true } rand = { workspace = true } rand_chacha = { workspace = true } @@ -128,6 +129,7 @@ v2-performance = [] generate-ts = ["ts-rs"] [build-dependencies] +anyhow = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } sqlx = { workspace = true, features = [ "runtime-tokio-rustls", diff --git a/nym-api/build.rs b/nym-api/build.rs index a2c0377698..10307c5dfd 100644 --- a/nym-api/build.rs +++ b/nym-api/build.rs @@ -1,13 +1,20 @@ use sqlx::{Connection, FromRow, SqliteConnection}; use std::env; +const SQLITE_DB_FILENAME: &str = "nym-api-example.sqlite"; + // it's fine if compilation fails #[allow(clippy::unwrap_used)] #[allow(clippy::expect_used)] #[tokio::main] async fn main() { let out_dir = env::var("OUT_DIR").unwrap(); - let database_path = format!("{}/nym-api-example.sqlite", out_dir); + let database_path = format!("{}/{}", out_dir, SQLITE_DB_FILENAME); + + #[cfg(target_family = "unix")] + write_db_path_to_file(&out_dir, SQLITE_DB_FILENAME) + .await + .ok(); let mut conn = SqliteConnection::connect(&format!("sqlite://{}?mode=rwc", database_path)) .await @@ -62,3 +69,32 @@ async fn main() { // not a valid windows path... but hey, it works... println!("cargo:rustc-env=DATABASE_URL=sqlite:///{}", &database_path); } + +/// use `./enter_db.sh` to inspect DB +#[cfg(target_family = "unix")] +async fn write_db_path_to_file(out_dir: &str, db_filename: &str) -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + use tokio::{fs::File, io::AsyncWriteExt}; + + if env::var("CI").is_ok() { + return Ok(()); + } + let mut file = File::create("settings.sql").await?; + let settings = ".mode columns +.headers on"; + file.write_all(settings.as_bytes()).await?; + + let mut file = File::create("enter_db.sh").await?; + let contents = format!( + "#!/bin/sh\n\ + sqlite3 -init settings.sql {}/{}", + out_dir, db_filename, + ); + file.write_all(contents.as_bytes()).await?; + + file.set_permissions(std::fs::Permissions::from_mode(0o755)) + .await + .map_err(anyhow::Error::from)?; + + Ok(()) +} diff --git a/nym-api/src/ecash/tests/mod.rs b/nym-api/src/ecash/tests/mod.rs index 443e68c7e0..0fe5282943 100644 --- a/nym-api/src/ecash/tests/mod.rs +++ b/nym-api/src/ecash/tests/mod.rs @@ -17,6 +17,7 @@ use crate::support::config; use crate::support::http::state::{AppState, ChainStatusCache, ForcedRefresh}; use crate::support::nyxd::Client; use crate::support::storage::NymApiStorage; +use crate::unstable_routes::account::cache::AddressInfoCache; use async_trait::async_trait; use axum::Router; use axum_test::http::StatusCode; @@ -1274,6 +1275,7 @@ impl TestFixture { AppState { nyxd_client, chain_status_cache: ChainStatusCache::new(Duration::from_secs(42)), + address_info_cache: AddressInfoCache::new(), forced_refresh: ForcedRefresh::new(true), nym_contract_cache: NymContractCache::new(), node_status_cache: NodeStatusCache::new(), diff --git a/nym-api/src/main.rs b/nym-api/src/main.rs index 28cf5cd48e..faf39f2c14 100644 --- a/nym-api/src/main.rs +++ b/nym-api/src/main.rs @@ -26,8 +26,8 @@ pub(crate) mod nym_contract_cache; pub(crate) mod nym_nodes; mod status; pub(crate) mod support; +mod unstable_routes; -// TODO rocket: remove all such Todos once rocket is phased out completely #[tokio::main] async fn main() -> Result<(), anyhow::Error> { cfg_if::cfg_if! {if #[cfg(feature = "console-subscriber")] { diff --git a/nym-api/src/node_status_api/models.rs b/nym-api/src/node_status_api/models.rs index b2af9a6c52..cb7672695f 100644 --- a/nym-api/src/node_status_api/models.rs +++ b/nym-api/src/node_status_api/models.rs @@ -327,7 +327,6 @@ pub(crate) type AxumResult = Result; // #[schema(title = "ErrorResponse")] pub(crate) struct AxumErrorResponse { message: RequestError, - // #[schema(value_type = u16)] status: StatusCode, } diff --git a/nym-api/src/nym_nodes/handlers/unstable/mod.rs b/nym-api/src/nym_nodes/handlers/unstable/mod.rs index f858065d2b..79a88cee29 100644 --- a/nym-api/src/nym_nodes/handlers/unstable/mod.rs +++ b/nym-api/src/nym_nodes/handlers/unstable/mod.rs @@ -46,7 +46,7 @@ pub(crate) mod semi_skimmed; pub(crate) mod skimmed; #[allow(deprecated)] -pub(crate) fn nym_node_routes_unstable() -> Router { +pub(crate) fn routes() -> Router { Router::new() .nest( "/skimmed", diff --git a/nym-api/src/support/cli/run.rs b/nym-api/src/support/cli/run.rs index 46300cec50..334a800657 100644 --- a/nym-api/src/support/cli/run.rs +++ b/nym-api/src/support/cli/run.rs @@ -27,6 +27,7 @@ use crate::support::http::RouterBuilder; use crate::support::nyxd; use crate::support::storage::runtime_migrations::m001_directory_services_v2_1::migrate_to_directory_services_v2_1; use crate::support::storage::NymApiStorage; +use crate::unstable_routes::account::cache::AddressInfoCache; use crate::{ circulating_supply_api, ecash, epoch_operations, network_monitor, node_describe_cache, node_status_api, nym_contract_cache, @@ -193,6 +194,7 @@ async fn start_nym_api_tasks_axum(config: &Config) -> anyhow::Result anyhow::Result> { -// let openapi_settings = rocket_okapi::settings::OpenApiSettings::default(); -// let mut rocket = rocket::build(); -// -// let mix_denom = network_details.network.chain_details.mix_denom.base.clone(); -// -// mount_endpoints_and_merged_docs! { -// rocket, -// "/v1".to_owned(), -// openapi_settings, -// "/" => (vec![], openapi::custom_openapi_spec()), -// "" => circulating_supply_api::circulating_supply_routes(&openapi_settings), -// "" => nym_contract_cache::nym_contract_cache_routes(&openapi_settings), -// "/status" => node_status_api::node_status_routes(&openapi_settings, config.network_monitor.enabled), -// "/network" => network_routes(&openapi_settings), -// "/api-status" => api_status_routes(&openapi_settings), -// "/ecash" => ecash::routes_open_api(&openapi_settings, config.ecash_signer.enabled), -// "" => nym_node_routes_deprecated(&openapi_settings), -// -// // => when we move those routes, we'll need to add a redirection for backwards compatibility -// "/unstable/nym-nodes" => nym_node_routes_next(&openapi_settings) -// } -// -// let rocket = rocket -// .manage(network_details) -// .manage(SharedCache::::new()) -// .mount("/swagger", make_swagger_ui(&openapi::get_docs())) -// .attach(setup_rocket_cors()?) -// .attach(NymContractCache::stage()) -// .attach(NodeStatusCache::stage()) -// .attach(CirculatingSupplyCache::stage(mix_denom.clone())) -// .manage(unstable::NodeInfoCache::default()) -// .manage(storage.clone()); -// -// let mut status_state = ApiStatusState::new(); -// -// let rocket = if config.ecash_signer.enabled { -// // make sure we have some tokens to cover multisig fees -// let balance = nyxd_client.balance(&mix_denom).await?; -// if balance.amount < ecash::MINIMUM_BALANCE { -// let address = nyxd_client.address().await; -// let min = Coin::new(ecash::MINIMUM_BALANCE, mix_denom); -// bail!("the account ({address}) doesn't have enough funds to cover verification fees. it has {balance} while it needs at least {min}") -// } -// -// let cosmos_address = nyxd_client.address().await.to_string(); -// let announce_address = config -// .ecash_signer -// .announce_address -// .clone() -// .map(|u| u.to_string()) -// .unwrap_or_default(); -// status_state.add_zk_nym_signer(SignerState { -// cosmos_address, -// identity: identity_keypair.public_key().to_base58_string(), -// announce_address, -// ecash_keypair: coconut_keypair.clone(), -// }); -// -// let ecash_contract = nyxd_client -// .get_ecash_contract_address() -// .await -// .context("e-cash contract address is required to setup the zk-nym signer")?; -// -// let comm_channel = QueryCommunicationChannel::new(nyxd_client.clone()); -// -// let ecash_state = EcashState::new( -// ecash_contract, -// nyxd_client.clone(), -// identity_keypair, -// coconut_keypair, -// comm_channel, -// storage.clone(), -// ) -// .await?; -// -// rocket.manage(ecash_state) -// } else { -// rocket -// }; -// -// Ok(rocket.manage(status_state).ignite().await?) -// } -// -// fn setup_rocket_cors() -> Result { -// let allowed_origins = AllowedOrigins::all(); -// -// // You can also deserialize this -// let cors = rocket_cors::CorsOptions { -// allowed_origins, -// allowed_methods: vec![rocket::http::Method::Post, rocket::http::Method::Get] -// .into_iter() -// .map(From::from) -// .collect(), -// allowed_headers: AllowedHeaders::all(), -// allow_credentials: true, -// ..Default::default() -// } -// .to_cors()?; -// -// Ok(cors) -// } +use crate::unstable_routes; diff --git a/nym-api/src/support/http/state.rs b/nym-api/src/support/http/state.rs index 224db98e53..2fc65ff5a0 100644 --- a/nym-api/src/support/http/state.rs +++ b/nym-api/src/support/http/state.rs @@ -14,6 +14,8 @@ use crate::support::caching::cache::SharedCache; use crate::support::caching::Cache; use crate::support::nyxd::Client; use crate::support::storage; +use crate::unstable_routes::account::cache::AddressInfoCache; +use crate::unstable_routes::models::NyxAccountDetails; use axum::extract::FromRef; use nym_api_requests::models::{ DetailedChainStatus, GatewayBondAnnotated, MixNodeBondAnnotated, NodeAnnotation, @@ -85,6 +87,7 @@ pub(crate) struct AppState { pub(crate) nyxd_client: Client, pub(crate) chain_status_cache: ChainStatusCache, + pub(crate) address_info_cache: AddressInfoCache, pub(crate) forced_refresh: ForcedRefresh, pub(crate) nym_contract_cache: NymContractCache, pub(crate) node_status_cache: NodeStatusCache, @@ -292,4 +295,43 @@ impl AppState { .await .ok_or_else(AxumErrorResponse::internal) } + + pub(crate) async fn get_address_info( + self, + account_id: nym_validator_client::nyxd::AccountId, + ) -> Result { + let address = account_id.to_string(); + match self.address_info_cache.get(&address).await { + Some(guard) => { + tracing::trace!("Fetching from cache..."); + let read_lock = guard.read().await; + Ok(read_lock.clone()) + } + None => { + tracing::trace!("No cache for {}, refreshing data...", &address); + + let address_info = self + .address_info_cache + .collect_balances( + self.nyxd_client.clone(), + self.nym_contract_cache.clone(), + self.network_details() + .network + .chain_details + .mix_denom + .base + .to_owned(), + &address, + account_id, + ) + .await?; + + self.address_info_cache + .upsert_address_info(&address, address_info.clone()) + .await; + + Ok(address_info) + } + } + } } diff --git a/nym-api/src/support/http/unstable_routes.rs b/nym-api/src/support/http/unstable_routes.rs deleted file mode 100644 index 7bc1a9644c..0000000000 --- a/nym-api/src/support/http/unstable_routes.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright 2024 - Nym Technologies SA -// SPDX-License-Identifier: GPL-3.0-only - -use crate::nym_nodes::handlers::unstable::nym_node_routes_unstable; -use crate::support::http::state::AppState; -use axum::Router; - -// as those get stabilised, they should get deprecated and use a redirection instead -pub(crate) fn unstable_routes() -> Router { - Router::new().nest("/nym-nodes", nym_node_routes_unstable()) -} diff --git a/nym-api/src/support/nyxd/mod.rs b/nym-api/src/support/nyxd/mod.rs index 889ffc0acd..75490e8a4c 100644 --- a/nym-api/src/support/nyxd/mod.rs +++ b/nym-api/src/support/nyxd/mod.rs @@ -29,14 +29,15 @@ use nym_mixnet_contract_common::mixnode::MixNodeDetails; use nym_mixnet_contract_common::nym_node::Role; use nym_mixnet_contract_common::reward_params::RewardingParams; use nym_mixnet_contract_common::{ - ConfigScoreParams, CurrentIntervalResponse, EpochRewardedSet, EpochStatus, ExecuteMsg, - GatewayBond, HistoricalNymNodeVersionEntry, IdentityKey, NymNodeDetails, RewardedSet, - RoleAssignment, + ConfigScoreParams, CurrentIntervalResponse, Delegation, EpochRewardedSet, EpochStatus, + ExecuteMsg, GatewayBond, HistoricalNymNodeVersionEntry, IdentityKey, NymNodeDetails, + RewardedSet, RoleAssignment, }; use nym_validator_client::coconut::EcashApiError; use nym_validator_client::nyxd::contract_traits::mixnet_query_client::MixnetQueryClientExt; use nym_validator_client::nyxd::contract_traits::PagedDkgQueryClient; use nym_validator_client::nyxd::error::NyxdError; +use nym_validator_client::nyxd::Coin; use nym_validator_client::nyxd::{ contract_traits::{ DkgQueryClient, DkgSigningClient, EcashQueryClient, GroupQueryClient, MixnetQueryClient, @@ -48,7 +49,7 @@ use nym_validator_client::nyxd::{ }; use nym_validator_client::nyxd::{ hash::{Hash, SHA256_HASH_SIZE}, - AccountId, Coin, TendermintTime, + AccountId, TendermintTime, }; use nym_validator_client::{ nyxd, DirectSigningHttpRpcNyxdClient, EcashApiClient, QueryHttpRpcNyxdClient, @@ -403,6 +404,21 @@ impl Client { nyxd_signing!(self, reconcile_epoch_events(limit, None).await?); Ok(()) } + + pub(crate) async fn get_all_delegator_delegations( + &self, + delegation_owner: &AccountId, + ) -> Result, NyxdError> { + nyxd_query!(self, get_all_delegator_delegations(delegation_owner).await) + } + + pub(crate) async fn get_address_balance( + &self, + address: &AccountId, + denom: impl Into, + ) -> Result, NyxdError> { + nyxd_query!(self, get_balance(&address, denom.into()).await) + } } #[async_trait] diff --git a/nym-api/src/unstable_routes/account/cache.rs b/nym-api/src/unstable_routes/account/cache.rs new file mode 100644 index 0000000000..261018962a --- /dev/null +++ b/nym-api/src/unstable_routes/account/cache.rs @@ -0,0 +1,129 @@ +use crate::{ + node_status_api::models::AxumResult, + nym_contract_cache::cache::NymContractCache, + unstable_routes::{ + account::data_collector::AddressDataCollector, + models::{NyxAccountDelegationDetails, NyxAccountDetails}, + }, +}; +use moka::{future::Cache, Entry}; +use nym_validator_client::nyxd::AccountId; +use std::{sync::Arc, time::Duration}; +use tokio::sync::RwLock; + +#[derive(Clone)] +pub(crate) struct AddressInfoCache { + inner: Cache>>, +} + +impl AddressInfoCache { + pub(crate) fn new() -> Self { + // epoch duration = 1 hour + // cache TTL is slightly lower than that to avoid too stale data in case + // cache was refreshed JUST BEFORE epoch transition + let cache_ttl = Duration::from_secs(60 * 30); + let max_capacity = 1000; + + AddressInfoCache { + inner: Cache::builder() + .time_to_live(cache_ttl) + .max_capacity(max_capacity) + .build(), + } + } + + pub(crate) async fn get(&self, key: &str) -> Option>> { + self.inner.get(key).await + } + + pub(crate) async fn upsert_address_info( + &self, + address: &str, + address_info: NyxAccountDetails, + ) -> Entry>> { + self.inner + .entry_by_ref(address) + .and_upsert_with(|maybe_entry| async { + if let Some(entry) = maybe_entry { + let v = entry.into_value(); + let mut guard = v.write().await; + *guard = address_info; + v.clone() + } else { + Arc::new(RwLock::new(address_info)) + } + }) + .await + } + + pub(crate) async fn collect_balances( + &self, + nyxd_client: crate::nyxd::Client, + nym_contract_cache: NymContractCache, + base_denom: String, + address: &str, + account_id: AccountId, + ) -> AxumResult { + let mut collector = AddressDataCollector::new( + nyxd_client, + nym_contract_cache, + base_denom, + account_id.clone(), + ); + + // ==> get balances of chain tokens <== + let balance = collector.get_address_balance().await?; + + // it's very difficult to lower existing balance to exactly 0 + // so assume this is an unused address and return early + if balance.amount == 0 { + let address_info = NyxAccountDetails { + address: address.to_string(), + balance: balance.clone().into(), + total_value: balance.clone().into(), + delegations: Vec::new(), + accumulated_rewards: Vec::new(), + total_delegations: balance.clone().into(), + claimable_rewards: balance.clone().into(), + operator_rewards: None, + }; + + return Ok(address_info); + } + + // ==> get list of delegations (history) <== + let delegation_data = collector.get_delegations().await?; + + // ==> get the current reward for each active delegation <== + // calculate rewards from nodes this delegator delegated to + let accumulated_rewards = collector.calculate_rewards(&delegation_data).await?; + + // ==> convert totals <== + let claimable_rewards = collector.claimable_rewards(); + let total_value = collector.total_value(); + let total_delegations = collector.total_delegations(); + let operator_rewards = collector.operator_rewards(); + + let address_info = NyxAccountDetails { + address: account_id.to_string(), + balance: balance.into(), + delegations: delegation_data + .delegations() + .into_iter() + .map(|d| NyxAccountDelegationDetails { + delegated: d.amount, + height: d.height, + node_id: d.node_id, + proxy: d.proxy, + }) + .collect(), + accumulated_rewards, + total_delegations, + claimable_rewards, + total_value, + operator_rewards, + }; + + Ok(address_info) + } +} diff --git a/nym-api/src/unstable_routes/account/data_collector.rs b/nym-api/src/unstable_routes/account/data_collector.rs new file mode 100644 index 0000000000..f9829b29a8 --- /dev/null +++ b/nym-api/src/unstable_routes/account/data_collector.rs @@ -0,0 +1,209 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::{ + node_status_api::models::{AxumErrorResponse, AxumResult}, + nym_contract_cache::cache::NymContractCache, + unstable_routes::models::NyxAccountDelegationRewardDetails, +}; +use cosmwasm_std::{Coin, Decimal}; +use nym_mixnet_contract_common::NodeRewarding; +use nym_topology::NodeId; +use nym_validator_client::nyxd::AccountId; +use std::collections::{HashMap, HashSet}; +use tracing::warn; + +pub(crate) struct AddressDataCollector { + nyxd_client: crate::nyxd::Client, + nym_contract_cache: NymContractCache, + account_id: AccountId, + total_value: u128, + operator_rewards: u128, + claimable_rewards: u128, + total_delegations: u128, + base_denom: String, +} + +impl AddressDataCollector { + pub(crate) fn new( + nyxd_client: crate::nyxd::Client, + nym_contract_cache: NymContractCache, + base_denom: String, + account_id: AccountId, + ) -> Self { + Self { + nyxd_client, + nym_contract_cache, + base_denom, + account_id, + total_value: 0, + operator_rewards: 0, + claimable_rewards: 0, + total_delegations: 0, + } + } + + pub(crate) async fn get_address_balance( + &mut self, + ) -> AxumResult { + let balance = self + .nyxd_client + .get_address_balance(&self.account_id, &self.base_denom) + .await? + .unwrap_or_else(|| nym_validator_client::nyxd::Coin::new(0u128, &self.base_denom)); + self.total_value += balance.amount; + + Ok(balance) + } + + pub(crate) async fn get_delegations(&mut self) -> AxumResult { + let og_delegations = self + .nyxd_client + .get_all_delegator_delegations(&self.account_id) + .await?; + + let delegated_to_nodes = og_delegations + .iter() + .map(|d| d.node_id) + .collect::>(); + + let nym_nodes = self + .nym_contract_cache + .all_cached_nym_nodes() + .await + .ok_or_else(AxumErrorResponse::service_unavailable)? + .iter() + .filter_map(|node_details| { + // is this an operator of this node? + if self.account_id.to_string() == node_details.bond_information.owner.as_str() { + let pending_operator_reward = + node_details.pending_operator_reward().amount.u128(); + + // add operator rewards + self.operator_rewards += pending_operator_reward; + + // add to totals + self.total_value += pending_operator_reward; + } + if delegated_to_nodes.contains(&node_details.node_id()) { + Some(( + node_details.node_id(), + // avoid cloning node data which we don't need + ( + node_details.rewarding_details.clone(), + node_details.is_unbonding(), + ), + )) + } else { + None + } + }) + .collect::>(); + + Ok(AddressDelegationInfo { + delegations: og_delegations, + delegated_to_nodes: nym_nodes, + }) + } + + pub(crate) async fn calculate_rewards( + &mut self, + delegation_data: &AddressDelegationInfo, + ) -> AxumResult> { + let mut accumulated_rewards = Vec::new(); + for delegation in delegation_data.delegations.iter() { + let node_id = &delegation.node_id; + + if let Some((rewarding_details, is_unbonding)) = + delegation_data.delegated_to_nodes.get(node_id) + { + match rewarding_details.determine_delegation_reward(delegation) { + Ok(delegation_reward) => { + let reward = NyxAccountDelegationRewardDetails { + node_id: delegation.node_id, + rewards: decimal_to_coin(delegation_reward, &self.base_denom), + amount_staked: delegation.amount.clone(), + node_still_fully_bonded: !is_unbonding, + }; + // 4. sum the rewards and delegations + self.total_delegations += delegation.amount.amount.u128(); + self.total_value += delegation.amount.amount.u128(); + self.total_value += reward.rewards.amount.u128(); + self.claimable_rewards += reward.rewards.amount.u128(); + + accumulated_rewards.push(reward); + } + Err(err) => { + warn!( + "Couldn't determine delegations for {} on node {}: {}", + &self.account_id, node_id, err + ) + } + } + } + } + + Ok(accumulated_rewards) + } + + pub(crate) fn claimable_rewards(&self) -> Coin { + Coin::new(self.claimable_rewards, self.base_denom.to_string()) + } + + pub(crate) fn total_value(&self) -> Coin { + Coin::new(self.total_value, self.base_denom.to_string()) + } + + pub(crate) fn total_delegations(&self) -> Coin { + Coin::new(self.total_delegations, self.base_denom.to_string()) + } + + pub(crate) fn operator_rewards(&self) -> Option { + if self.operator_rewards > 0 { + Some(Coin::new( + self.operator_rewards, + self.base_denom.to_string(), + )) + } else { + None + } + } +} + +pub(crate) struct AddressDelegationInfo { + delegations: Vec, + delegated_to_nodes: HashMap, +} + +impl AddressDelegationInfo { + pub(crate) fn delegations(self) -> Vec { + self.delegations + } +} + +type RewardAndBondInfo = (NodeRewarding, bool); + +fn decimal_to_coin(decimal: Decimal, denom: impl Into) -> Coin { + Coin::new(decimal.to_uint_floor(), denom) +} + +#[cfg(test)] +mod test { + use super::*; + + #[tokio::test] + async fn decimal_to_coin_test() { + let test_values = [ + (1234, 0, 1234), + (1234, 2, 12), + (1_234_000_000_000_000u128, 6, 1_234_000_000u128), + ]; + + for (amount, decimal_places, coin_amount) in test_values { + let decimal = + Decimal::from_atomics(cosmwasm_std::Uint128::new(amount), decimal_places).unwrap(); + let coin_from_decimal = decimal_to_coin(decimal, "unym"); + assert_eq!(coin_from_decimal, Coin::new(coin_amount, "unym")); + } + } +} diff --git a/nym-api/src/unstable_routes/account/mod.rs b/nym-api/src/unstable_routes/account/mod.rs new file mode 100644 index 0000000000..255f49b473 --- /dev/null +++ b/nym-api/src/unstable_routes/account/mod.rs @@ -0,0 +1,54 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use crate::{ + node_status_api::models::{AxumErrorResponse, AxumResult}, + support::http::state::AppState, + unstable_routes::models::NyxAccountDetails, +}; +use axum::{ + extract::{Path, State}, + routing::get, + Json, Router, +}; +use nym_validator_client::nyxd::AccountId; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use tracing::{error, instrument}; +use utoipa::ToSchema; + +pub(crate) mod cache; +pub(crate) mod data_collector; + +pub(crate) fn routes() -> Router { + Router::new().route("/:address", get(address)) +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, utoipa::IntoParams)] +pub struct AddressQueryParam { + #[serde(default)] + pub address: String, +} + +#[utoipa::path( + tag = "Unstable", + get, + path = "/{address}", + context_path = "/v1/unstable/account", + responses( + (status = 200, body = NyxAccountDetails) + ), + params(AddressQueryParam) +)] +#[instrument(level = "info", skip_all, fields(address=address))] +async fn address( + Path(AddressQueryParam { address }): Path, + State(state): State, +) -> AxumResult> { + let account_id = AccountId::from_str(&address).map_err(|err| { + error!("{err}"); + AxumErrorResponse::not_found(&address) + })?; + + state.get_address_info(account_id).await.map(Json) +} diff --git a/nym-api/src/unstable_routes/mod.rs b/nym-api/src/unstable_routes/mod.rs new file mode 100644 index 0000000000..d48ecb1e1e --- /dev/null +++ b/nym-api/src/unstable_routes/mod.rs @@ -0,0 +1,15 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +pub(crate) mod account; +pub(crate) mod models; + +use crate::support::http::state::AppState; +use axum::Router; + +// as those get stabilised, they should get deprecated and use a redirection instead +pub(crate) fn unstable_routes() -> Router { + Router::new() + .nest("/nym-nodes", crate::nym_nodes::handlers::unstable::routes()) + .nest("/account", account::routes()) +} diff --git a/nym-api/src/unstable_routes/models.rs b/nym-api/src/unstable_routes/models.rs new file mode 100644 index 0000000000..9864f19a42 --- /dev/null +++ b/nym-api/src/unstable_routes/models.rs @@ -0,0 +1,51 @@ +// Copyright 2025 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use cosmwasm_std::{Addr, Coin}; +use nym_topology::NodeId; +use serde::{Deserialize, Serialize}; +use utoipa::schema; + +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema, utoipa::ToResponse)] +#[schema(title = "Coin")] +pub struct CoinSchema { + pub denom: String, + pub amount: u128, +} + +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema, utoipa::ToResponse)] +pub struct NyxAccountDelegationDetails { + pub node_id: NodeId, + #[schema(value_type = CoinSchema)] + pub delegated: Coin, + pub height: u64, + #[schema(value_type = Option)] + pub proxy: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema, utoipa::ToResponse)] +pub struct NyxAccountDelegationRewardDetails { + pub node_id: NodeId, + #[schema(value_type = CoinSchema)] + pub rewards: Coin, + #[schema(value_type = String)] + pub amount_staked: Coin, + pub node_still_fully_bonded: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema, utoipa::ToResponse)] +pub struct NyxAccountDetails { + pub address: String, + #[schema(value_type = CoinSchema)] + pub balance: Coin, + #[schema(value_type = CoinSchema)] + pub total_value: Coin, + pub delegations: Vec, + pub accumulated_rewards: Vec, + #[schema(value_type = String)] + pub total_delegations: Coin, + #[schema(value_type = CoinSchema)] + pub claimable_rewards: Coin, + #[schema(value_type = Option)] + pub operator_rewards: Option, +}