diff --git a/Cargo.lock b/Cargo.lock index 66d513f9..83dd0e1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6834,6 +6834,7 @@ dependencies = [ "namada_proof_of_stake", "namada_sdk", "namada_tx", + "num-bigint", "rand", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index c1fd0402..d1e50fb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ namada_ibc = { git = "https://github.com/anoma/namada", tag = "libs-v0.47.0" } namada_token = { git = "https://github.com/anoma/namada", tag = "libs-v0.47.0" } namada_parameters = { git = "https://github.com/anoma/namada", tag = "libs-v0.47.0" } namada_proof_of_stake = { git = "https://github.com/anoma/namada", tag = "libs-v0.47.0" } +num-bigint = "0.4.6" tendermint = "0.38.0" tendermint-config = "0.38.0" tendermint-rpc = { version = "0.38.0", features = ["http-client"] } diff --git a/chain/src/main.rs b/chain/src/main.rs index 10a22f0f..f60305b1 100644 --- a/chain/src/main.rs +++ b/chain/src/main.rs @@ -26,6 +26,7 @@ use shared::checksums::Checksums; use shared::crawler::crawl; use shared::crawler_state::ChainCrawlerState; use shared::error::{AsDbError, AsRpcError, ContextDbInteractError, MainError}; +use shared::futures::AwaitContainer; use shared::id::Id; use shared::token::Token; use shared::utils::BalanceChange; @@ -235,6 +236,16 @@ async fn crawling_fn( )); let addresses = block.addresses_with_balance_change(&native_token); + let native_token_supplies = first_block_in_epoch + .eq(&block_height) + .then(|| { + namada_service::get_token_supplies(&client, &native_token, epoch) + }) + .future() + .await + .transpose() + .into_rpc_error()?; + let validators_addresses = if first_block_in_epoch.eq(&block_height) { namada_service::get_all_consensus_validators_addresses_at( &client, @@ -411,6 +422,11 @@ async fn crawling_fn( ibc_tokens, )?; + repository::balance::insert_token_supplies( + transaction_conn, + native_token_supplies, + )?; + repository::block::upsert_block( transaction_conn, block, diff --git a/chain/src/repository/balance.rs b/chain/src/repository/balance.rs index 253e8c3e..cf66d300 100644 --- a/chain/src/repository/balance.rs +++ b/chain/src/repository/balance.rs @@ -1,9 +1,12 @@ use anyhow::Context; use diesel::{PgConnection, RunQueryDsl}; use orm::balances::BalanceChangesInsertDb; -use orm::schema::{balance_changes, ibc_token, token}; +use orm::schema::{ + balance_changes, ibc_token, token, token_supplies_per_epoch, +}; use orm::token::{IbcTokenInsertDb, TokenInsertDb}; -use shared::balance::Balances; +use orm::token_supplies_per_epoch::TokenSuppliesInsertDb; +use shared::balance::{Balances, TokenSupply}; use shared::token::Token; use shared::tuple_len::TupleLen; @@ -70,6 +73,33 @@ pub fn insert_tokens( anyhow::Ok(()) } +pub fn insert_token_supplies( + transaction_conn: &mut PgConnection, + supplies: S, +) -> anyhow::Result<()> +where + S: IntoIterator, +{ + let supplies: Vec<_> = supplies + .into_iter() + .map(TokenSuppliesInsertDb::from) + .collect(); + + if supplies.is_empty() { + return anyhow::Ok(()); + } + + tracing::debug!(?supplies, "Adding new token supplies to db"); + + diesel::insert_into(token_supplies_per_epoch::table) + .values(supplies) + .on_conflict_do_nothing() + .execute(transaction_conn) + .context("Failed to update token supplies in db")?; + + anyhow::Ok(()) +} + #[cfg(test)] mod tests { diff --git a/chain/src/services/namada.rs b/chain/src/services/namada.rs index 8abc5a3b..08349217 100644 --- a/chain/src/services/namada.rs +++ b/chain/src/services/namada.rs @@ -18,7 +18,7 @@ use namada_sdk::rpc::{ use namada_sdk::state::Key; use namada_sdk::token::Amount as NamadaSdkAmount; use namada_sdk::{borsh, rpc, token}; -use shared::balance::{Amount, Balance, Balances}; +use shared::balance::{Amount, Balance, Balances, TokenSupply}; use shared::block::{BlockHeight, Epoch}; use shared::bond::{Bond, BondAddresses, Bonds}; use shared::id::Id; @@ -42,6 +42,30 @@ pub async fn get_native_token(client: &HttpClient) -> anyhow::Result { Ok(Id::from(native_token)) } +pub async fn query_native_token_total_supply( + client: &HttpClient, +) -> anyhow::Result { + let native_token = RPC + .shell() + .native_token(client) + .await + .context("Failed to query native token")?; + + rpc::get_token_total_supply(client, &native_token) + .await + .map(Amount::from) + .context("Failed to query total supply of native token") +} + +pub async fn query_native_token_effective_supply( + client: &HttpClient, +) -> anyhow::Result { + rpc::get_effective_native_supply(client) + .await + .map(Amount::from) + .context("Failed to query effective supply of native token") +} + pub async fn get_first_block_in_epoch( client: &HttpClient, ) -> anyhow::Result { @@ -797,3 +821,23 @@ pub async fn get_pgf_receipients( }) .collect::>() } + +pub async fn get_token_supplies( + client: &HttpClient, + native_token: &Id, + epoch: u32, +) -> anyhow::Result { + let total_supply_fut = query_native_token_total_supply(client); + let effective_supply_fut = query_native_token_effective_supply(client); + + let (total_supply, effective_supply) = + futures::try_join!(total_supply_fut, effective_supply_fut) + .context("Failed to query native token supplies")?; + + anyhow::Ok(TokenSupply { + address: native_token.to_string(), + epoch: epoch as _, + total: total_supply.into(), + effective: Some(effective_supply.into()), + }) +} diff --git a/justfile b/justfile index 93d33be8..a4a2c2ae 100644 --- a/justfile +++ b/justfile @@ -9,8 +9,8 @@ toolchains: @echo {{ RUST_STABLE }} @echo {{ RUST_NIGTHLY }} -build: - cargo +{{ RUST_STABLE }} build --all +build *BIN: + cargo +{{ RUST_STABLE }} build {{ if BIN != "" { prepend("--bin ", BIN) } else { "--all" } }} check: cargo +{{ RUST_STABLE }} check --all diff --git a/orm/migrations/2025-02-06-093718_token_supplies/down.sql b/orm/migrations/2025-02-06-093718_token_supplies/down.sql new file mode 100644 index 00000000..6af45f90 --- /dev/null +++ b/orm/migrations/2025-02-06-093718_token_supplies/down.sql @@ -0,0 +1,11 @@ +-- This file should undo anything in `up.sql` + +-- Drop the foreign key constraint on the address column +ALTER TABLE token_supplies_per_epoch + DROP CONSTRAINT fk_token_supplies_per_epoch_address; + +-- Drop the trigger for enforcing the effective constraint +DROP TRIGGER enforce_effective_constraint ON token_supplies_per_epoch; + +-- Drop the table itself +DROP TABLE token_supplies_per_epoch; diff --git a/orm/migrations/2025-02-06-093718_token_supplies/up.sql b/orm/migrations/2025-02-06-093718_token_supplies/up.sql new file mode 100644 index 00000000..9d73bc99 --- /dev/null +++ b/orm/migrations/2025-02-06-093718_token_supplies/up.sql @@ -0,0 +1,39 @@ +-- Your SQL goes here + +CREATE TABLE token_supplies_per_epoch ( + id SERIAL PRIMARY KEY, + address VARCHAR(45) NOT NULL, + epoch INT NOT NULL, + -- `2^256 - 1` will fit in `NUMERIC(78, 0)` + total NUMERIC(78, 0) NOT NULL, + effective NUMERIC(78, 0), + -- reference the `address` column in the `token` table + CONSTRAINT fk_token_supplies_per_epoch_address + FOREIGN KEY(address) REFERENCES token(address) ON DELETE CASCADE +); + +ALTER TABLE token_supplies_per_epoch ADD UNIQUE (address, epoch); + +CREATE OR REPLACE FUNCTION check_effective_for_token_type() +RETURNS TRIGGER AS $$ +BEGIN + -- Check if the referenced token_type is 'native' + IF EXISTS ( + SELECT 1 + FROM token + WHERE token.address = NEW.address AND token.token_type = 'native' + ) THEN + -- If token_type is 'native', ensure token_supplies_per_epoch.effective is not NULL + IF NEW.effective IS NULL THEN + RAISE EXCEPTION 'token_supplies_per_epoch.effective cannot be NULL when token.token_type is ''native'''; + END IF; + END IF; + -- Allow the insert or update to proceed + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER enforce_effective_constraint +BEFORE INSERT OR UPDATE ON token_supplies_per_epoch +FOR EACH ROW +EXECUTE FUNCTION check_effective_for_token_type(); diff --git a/orm/src/lib.rs b/orm/src/lib.rs index 2427200c..2a71355a 100644 --- a/orm/src/lib.rs +++ b/orm/src/lib.rs @@ -15,6 +15,7 @@ pub mod pos_rewards; pub mod revealed_pk; pub mod schema; pub mod token; +pub mod token_supplies_per_epoch; pub mod transactions; pub mod unbond; pub mod validators; diff --git a/orm/src/schema.rs b/orm/src/schema.rs index 38a7d121..98920148 100644 --- a/orm/src/schema.rs +++ b/orm/src/schema.rs @@ -327,6 +327,17 @@ diesel::table! { } } +diesel::table! { + token_supplies_per_epoch (id) { + id -> Int4, + #[max_length = 45] + address -> Varchar, + epoch -> Int4, + total -> Numeric, + effective -> Nullable, + } +} + diesel::table! { use diesel::sql_types::*; use super::sql_types::HistoryKind; @@ -397,6 +408,7 @@ diesel::joinable!(ibc_token -> token (address)); diesel::joinable!(inner_transactions -> wrapper_transactions (wrapper_id)); diesel::joinable!(pos_rewards -> validators (validator_id)); diesel::joinable!(public_good_funding -> governance_proposals (proposal_id)); +diesel::joinable!(token_supplies_per_epoch -> token (address)); diesel::joinable!(transaction_history -> inner_transactions (inner_tx_id)); diesel::joinable!(unbonds -> validators (validator_id)); diesel::joinable!(wrapper_transactions -> blocks (block_height)); @@ -419,6 +431,7 @@ diesel::allow_tables_to_appear_in_same_query!( public_good_funding, revealed_pk, token, + token_supplies_per_epoch, transaction_history, unbonds, validators, diff --git a/orm/src/token_supplies_per_epoch.rs b/orm/src/token_supplies_per_epoch.rs new file mode 100644 index 00000000..d6fe3e78 --- /dev/null +++ b/orm/src/token_supplies_per_epoch.rs @@ -0,0 +1,44 @@ +use bigdecimal::BigDecimal; +use diesel::{Insertable, Queryable, Selectable}; +use shared::balance::TokenSupply as SharedTokenSupply; + +use crate::schema::token_supplies_per_epoch; + +#[derive(Debug, Clone, Queryable, Selectable)] +#[diesel(table_name = token_supplies_per_epoch)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct TokenSuppliesDb { + pub id: i32, + pub address: String, + pub epoch: i32, + pub total: BigDecimal, + pub effective: Option, +} + +#[derive(Debug, Clone, Queryable, Selectable, Insertable)] +#[diesel(table_name = token_supplies_per_epoch)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct TokenSuppliesInsertDb { + pub address: String, + pub epoch: i32, + pub total: BigDecimal, + pub effective: Option, +} + +impl From for TokenSuppliesInsertDb { + fn from(supply: SharedTokenSupply) -> Self { + let SharedTokenSupply { + address, + epoch, + total, + effective, + } = supply; + + Self { + address, + epoch, + total, + effective, + } + } +} diff --git a/shared/Cargo.toml b/shared/Cargo.toml index c8a8ae21..a3f4998a 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -28,6 +28,7 @@ namada_proof_of_stake.workspace = true namada_ibc.workspace = true namada_sdk.workspace = true namada_tx.workspace = true +num-bigint.workspace = true serde.workspace = true serde_json.workspace = true subtle-encoding.workspace = true @@ -39,4 +40,4 @@ tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true fake.workspace = true -rand.workspace = true \ No newline at end of file +rand.workspace = true diff --git a/shared/src/balance.rs b/shared/src/balance.rs index 9c96d9ba..9f4719fa 100644 --- a/shared/src/balance.rs +++ b/shared/src/balance.rs @@ -6,6 +6,7 @@ use namada_sdk::token::{ Amount as NamadaAmount, DenominatedAmount as NamadaDenominatedAmount, Denomination as NamadaDenomination, }; +use num_bigint::BigUint; use crate::id::Id; use crate::token::Token; @@ -21,10 +22,37 @@ impl From for Amount { impl From for Amount { fn from(amount: BigDecimal) -> Amount { - Amount( - NamadaAmount::from_string_precise(&amount.to_string()) - .expect("Invalid amount"), - ) + (&amount).into() + } +} + +impl From<&BigDecimal> for Amount { + fn from(amount: &BigDecimal) -> Amount { + let (big_int, _scale) = amount.as_bigint_and_scale(); + let (_sign, amount_bytes) = big_int.to_bytes_le(); + + let uint_bytes: [u64; 4] = { + // interpret as uint, regardless of sign. we + // also truncate to the first 32 bytes. + let mut uint_bytes = [0u8; 32]; + let min_len = amount_bytes.len().min(32); + + uint_bytes[..min_len].copy_from_slice(&amount_bytes[..min_len]); + + unsafe { std::mem::transmute(uint_bytes) } + }; + + Amount(NamadaAmount::from(namada_core::uint::Uint(uint_bytes))) + } +} + +impl From for BigDecimal { + fn from(amount: Amount) -> BigDecimal { + let digits: [u8; 32] = { + let uint: namada_core::uint::Uint = amount.0.into(); + unsafe { std::mem::transmute(uint.0) } + }; + BigDecimal::from_biguint(BigUint::from_bytes_le(&digits), 0) } } @@ -114,3 +142,46 @@ impl Balance { } } } + +#[derive(Debug, Clone)] +pub struct TokenSupply { + pub address: String, + pub epoch: i32, + pub total: BigDecimal, + pub effective: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn conversion_between_bigdec_and_amount() { + let initial_amount = + Amount(NamadaAmount::from(namada_core::uint::Uint([ + 1u64, 2u64, 3u64, 4u64, + ]))); + + let initial_bigdec: BigDecimal = + "25108406941546723056364004793593481054836439088298861789185" + .parse() + .unwrap(); + + assert_eq!(initial_amount, Amount::from(&initial_bigdec)); + assert_eq!(BigDecimal::from(initial_amount.clone()), initial_bigdec); + + let amount_round_trip = { + let x: BigDecimal = initial_amount.clone().into(); + let x: Amount = x.into(); + x + }; + assert_eq!(initial_amount, amount_round_trip); + + let bigdec_round_trip = { + let x: Amount = (&initial_bigdec).into(); + let x: BigDecimal = x.into(); + x + }; + assert_eq!(initial_bigdec, bigdec_round_trip); + } +} diff --git a/shared/src/futures.rs b/shared/src/futures.rs new file mode 100644 index 00000000..e7b72789 --- /dev/null +++ b/shared/src/futures.rs @@ -0,0 +1,53 @@ +use std::future::Future; + +pub trait AwaitContainer { + type Output; + + #[allow(async_fn_in_trait)] + async fn future(self) -> Self::Output + where + F: Future; +} + +impl AwaitContainer for Option { + type Output = Option; + + #[inline] + async fn future(self) -> Option + where + F: Future, + { + if let Some(fut) = self { + Some(fut.await) + } else { + None + } + } +} + +impl AwaitContainer for Result { + type Output = Result; + + #[inline] + async fn future(self) -> Result + where + F: Future, + { + match self { + Ok(fut) => Ok(fut.await), + Err(err) => Err(err), + } + } +} + +impl AwaitContainer for Vec { + type Output = Vec; + + #[inline] + async fn future(self) -> Vec + where + F: Future, + { + ::futures::future::join_all(self).await + } +} diff --git a/shared/src/lib.rs b/shared/src/lib.rs index e495659e..1d210065 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -6,6 +6,7 @@ pub mod checksums; pub mod crawler; pub mod crawler_state; pub mod error; +pub mod futures; pub mod gas; pub mod genesis; pub mod header; diff --git a/swagger.yml b/swagger.yml index 478570cb..35383508 100644 --- a/swagger.yml +++ b/swagger.yml @@ -656,6 +656,29 @@ paths: value: - address: tnam1pkg30gnt4q0zn7j00r6hms4ajrxn6f5ysyyl7w9m trace: transfer/channel-2/uatom + /api/v1/chain/token-supply: + get: + summary: Get the supply of some token at the given epoch + parameters: + - in: query + name: address + schema: + type: string + required: true + description: Address of the token + - in: query + name: epoch + schema: + type: integer + minimum: 0 + description: Epoch to query + responses: + "200": + description: Chain token supply + content: + application/json: + schema: + $ref: "#/components/schemas/TokenSupply" /api/v1/chain/parameters: get: summary: Get chain parameters @@ -1140,6 +1163,20 @@ components: type: string trace: type: string + TokenSupply: + type: object + required: + [ + address, + totalSupply, + ] + properties: + address: + type: string + totalSupply: + type: string + effectiveSupply: + type: string Parameters: type: object required: diff --git a/webserver/src/app.rs b/webserver/src/app.rs index 1a822957..e11ffcf4 100644 --- a/webserver/src/app.rs +++ b/webserver/src/app.rs @@ -131,6 +131,10 @@ impl ApplicationServer { .route("/chain/parameters", get(chain_handlers::get_parameters)) .route("/chain/rpc-url", get(chain_handlers::get_rpc_url)) .route("/chain/token", get(chain_handlers::get_tokens)) + .route( + "/chain/token-supply", + get(chain_handlers::get_token_supply), + ) .route( "/chain/block/latest", get(chain_handlers::get_last_processed_block), diff --git a/webserver/src/dto/chain.rs b/webserver/src/dto/chain.rs new file mode 100644 index 00000000..a8119867 --- /dev/null +++ b/webserver/src/dto/chain.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; +use validator::Validate; + +#[derive(Clone, Serialize, Deserialize, Validate)] +#[serde(rename_all = "camelCase")] +pub struct TokenSupply { + #[validate(range(min = 0))] + pub epoch: Option, + pub address: String, +} diff --git a/webserver/src/dto/mod.rs b/webserver/src/dto/mod.rs index 207e9644..d967031d 100644 --- a/webserver/src/dto/mod.rs +++ b/webserver/src/dto/mod.rs @@ -1,3 +1,4 @@ +pub mod chain; pub mod crawler_state; pub mod gas; pub mod governance; diff --git a/webserver/src/handler/chain.rs b/webserver/src/handler/chain.rs index e4961e1d..6647969c 100644 --- a/webserver/src/handler/chain.rs +++ b/webserver/src/handler/chain.rs @@ -6,12 +6,15 @@ use axum::extract::State; use axum::http::HeaderMap; use axum::response::Sse; use axum::response::sse::{Event, KeepAlive}; +use axum_extra::extract::Query; use futures::Stream; use tokio_stream::StreamExt; +use crate::dto::chain::TokenSupply as TokenSupplyDto; use crate::error::api::ApiError; use crate::response::chain::{ LastProcessedBlock, LastProcessedEpoch, Parameters, RpcUrl, Token, + TokenSupply as TokenSupplyRsp, }; use crate::state::common::CommonState; @@ -99,3 +102,14 @@ pub async fn get_last_processed_epoch( epoch: last_processed_block.to_string(), })) } + +pub async fn get_token_supply( + Query(query): Query, + State(state): State, +) -> Result>, ApiError> { + let supply = state + .chain_service + .get_token_supply(query.address, query.epoch) + .await?; + Ok(Json(supply)) +} diff --git a/webserver/src/repository/chain.rs b/webserver/src/repository/chain.rs index 247429cf..0d838f90 100644 --- a/webserver/src/repository/chain.rs +++ b/webserver/src/repository/chain.rs @@ -1,12 +1,16 @@ use axum::async_trait; use diesel::dsl::max; use diesel::{ - ExpressionMethods, JoinOnDsl, QueryDsl, RunQueryDsl, SelectableHelper, + BoolExpressionMethods, ExpressionMethods, JoinOnDsl, OptionalExtension, + QueryDsl, RunQueryDsl, SelectableHelper, }; use orm::crawler_state::{ChainCrawlerStateDb, CrawlerNameDb}; use orm::parameters::ParametersDb; -use orm::schema::{chain_parameters, crawler_state, ibc_token, token}; +use orm::schema::{ + chain_parameters, crawler_state, ibc_token, token, token_supplies_per_epoch, +}; use orm::token::{IbcTokenDb, TokenDb}; +use orm::token_supplies_per_epoch::TokenSuppliesDb; use crate::appstate::AppState; @@ -30,6 +34,12 @@ pub trait ChainRepositoryTrait { async fn find_tokens( &self, ) -> Result)>, String>; + + async fn get_token_supply( + &self, + address: String, + epoch: Option, + ) -> Result, String>; } #[async_trait] @@ -120,4 +130,47 @@ impl ChainRepositoryTrait for ChainRepository { .map_err(|e| e.to_string())? .map_err(|e| e.to_string()) } + + async fn get_token_supply( + &self, + address: String, + epoch: Option, + ) -> Result, String> { + let conn = self.app_state.get_db_connection().await; + + conn.interact(move |conn| { + conn.build_transaction().read_only().run(move |conn| { + let epoch = epoch.map_or_else( + || { + crawler_state::dsl::crawler_state + .filter( + crawler_state::dsl::name + .eq(CrawlerNameDb::Chain), + ) + .select(max( + crawler_state::dsl::last_processed_epoch, + )) + .first::>(conn) + .map(|maybe_epoch| maybe_epoch.unwrap_or(0i32)) + }, + Ok, + )?; + + token_supplies_per_epoch::table + .filter( + token_supplies_per_epoch::dsl::address + .eq(&address) + .and( + token_supplies_per_epoch::dsl::epoch.eq(&epoch), + ), + ) + .select(TokenSuppliesDb::as_select()) + .first(conn) + .optional() + }) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string()) + } } diff --git a/webserver/src/response/chain.rs b/webserver/src/response/chain.rs index ecaebdff..a6ac4b9a 100644 --- a/webserver/src/response/chain.rs +++ b/webserver/src/response/chain.rs @@ -97,3 +97,11 @@ impl From for Token { } } } + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenSupply { + pub address: String, + pub total_supply: String, + pub effective_supply: Option, +} diff --git a/webserver/src/service/chain.rs b/webserver/src/service/chain.rs index be989f26..4e508bc9 100644 --- a/webserver/src/service/chain.rs +++ b/webserver/src/service/chain.rs @@ -4,7 +4,7 @@ use shared::token::{IbcToken, Token}; use crate::appstate::AppState; use crate::error::chain::ChainError; use crate::repository::chain::{ChainRepository, ChainRepositoryTrait}; -use crate::response::chain::Parameters; +use crate::response::chain::{Parameters, TokenSupply}; #[derive(Clone)] pub struct ChainService { @@ -65,4 +65,22 @@ impl ChainService { Ok(tokens) } + + pub async fn get_token_supply( + &self, + address: String, + epoch: Option, + ) -> Result, ChainError> { + let maybe_token_supply_db = self + .chain_repo + .get_token_supply(address, epoch) + .await + .map_err(ChainError::Database)?; + + Ok(maybe_token_supply_db.map(|supply| TokenSupply { + address: supply.address, + total_supply: supply.total.to_string(), + effective_supply: supply.effective.map(|s| s.to_string()), + })) + } }