diff --git a/Cargo.toml b/Cargo.toml index ccaa1ea9..b3079061 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" -members = ["chain", "shared", "rewards", "orm", "pos", "governance", "webserver", "seeder", "parameters"] +members = ["chain", "shared", "rewards", "orm", "pos", "governance", "webserver", "seeder"] [workspace.package] authors = ["Heliax "] @@ -46,6 +46,7 @@ namada_governance = { git = "https://github.com/anoma/namada", tag = "v0.38.1" } namada_ibc = { git = "https://github.com/anoma/namada", tag = "v0.38.1" } namada_token = { git = "https://github.com/anoma/namada", tag = "v0.38.1" } namada_parameters = { git = "https://github.com/anoma/namada", tag = "v0.38.1" } +namada_proof_of_stake = { git = "https://github.com/anoma/namada", tag = "v0.38.1" } tendermint = "0.36.0" tendermint-config = "0.36.0" tendermint-rpc = { version = "0.36.0", features = ["http-client"] } diff --git a/orm/migrations/2024-04-30-081808_init_validators/down.sql b/orm/migrations/2024-04-30-081808_init_validators/down.sql index 610b0cef..88c1f21e 100644 --- a/orm/migrations/2024-04-30-081808_init_validators/down.sql +++ b/orm/migrations/2024-04-30-081808_init_validators/down.sql @@ -1,3 +1,4 @@ -- This file should undo anything in `up.sql` +DROP TYPE VALIDATOR_STATE; DROP TABLE IF EXISTS validators; diff --git a/orm/migrations/2024-04-30-081808_init_validators/up.sql b/orm/migrations/2024-04-30-081808_init_validators/up.sql index 84911bba..beb3e8ca 100644 --- a/orm/migrations/2024-04-30-081808_init_validators/up.sql +++ b/orm/migrations/2024-04-30-081808_init_validators/up.sql @@ -1,5 +1,7 @@ -- Your SQL goes here +CREATE TYPE VALIDATOR_STATE AS ENUM ('active', 'inactive', 'jailed'); + CREATE TABLE validators ( id SERIAL PRIMARY KEY, namada_address VARCHAR NOT NULL, @@ -11,7 +13,8 @@ CREATE TABLE validators ( website VARCHAR, description VARCHAR, discord_handle VARCHAR, - avatar VARCHAR + avatar VARCHAR, + state VALIDATOR_STATE NOT NULL ); ALTER TABLE validators diff --git a/orm/src/schema.rs b/orm/src/schema.rs index 57200bfe..92eb4e48 100644 --- a/orm/src/schema.rs +++ b/orm/src/schema.rs @@ -25,6 +25,14 @@ pub mod sql_types { #[diesel(postgres_type(name = "governance_tally_type"))] pub struct GovernanceTallyType; + #[derive( + diesel::query_builder::QueryId, + std::fmt::Debug, + diesel::sql_types::SqlType, + )] + #[diesel(postgres_type(name = "validator_state"))] + pub struct ValidatorState; + #[derive( diesel::query_builder::QueryId, std::fmt::Debug, @@ -139,6 +147,9 @@ diesel::table! { } diesel::table! { + use diesel::sql_types::*; + use super::sql_types::ValidatorState; + validators (id) { id -> Int4, namada_address -> Varchar, @@ -151,6 +162,7 @@ diesel::table! { description -> Nullable, discord_handle -> Nullable, avatar -> Nullable, + state -> ValidatorState, } } diff --git a/orm/src/validators.rs b/orm/src/validators.rs index 00c3aba6..0a0201d1 100644 --- a/orm/src/validators.rs +++ b/orm/src/validators.rs @@ -1,12 +1,36 @@ use std::str::FromStr; -use diesel::{AsChangeset, Identifiable, Insertable, Queryable, Selectable}; -use serde::Serialize; -use shared::validator::Validator; +use diesel::{AsChangeset, Insertable, Queryable, Selectable}; +use serde::{Deserialize, Serialize}; +use shared::validator::{Validator, ValidatorState}; use crate::schema::validators; -#[derive(Identifiable, Serialize, Queryable, Selectable, Clone, Debug)] +#[derive(Debug, Clone, Serialize, Deserialize, diesel_derive_enum::DbEnum)] +#[ExistingTypePath = "crate::schema::sql_types::ValidatorState"] +pub enum ValidatorStateDb { + Consensus, + BelowCapacity, + BelowThreshold, + Inactive, + Jailed, + Unknown, +} + +impl From for ValidatorStateDb { + fn from(value: ValidatorState) -> Self { + match value { + ValidatorState::Consensus => Self::Consensus, + ValidatorState::BelowCapacity => Self::BelowCapacity, + ValidatorState::BelowThreshold => Self::BelowThreshold, + ValidatorState::Inactive => Self::Inactive, + ValidatorState::Jailed => Self::Jailed, + ValidatorState::Unknown => Self::Unknown, + } + } +} + +#[derive(Serialize, Queryable, Selectable, Clone)] #[diesel(table_name = validators)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct ValidatorDb { @@ -21,6 +45,7 @@ pub struct ValidatorDb { pub description: Option, pub discord_handle: Option, pub avatar: Option, + pub state: ValidatorStateDb, } #[derive(Serialize, Insertable, Clone)] diff --git a/pos/src/main.rs b/pos/src/main.rs index 62a00ccd..dec7c5cd 100644 --- a/pos/src/main.rs +++ b/pos/src/main.rs @@ -122,7 +122,6 @@ async fn crawling_fn( .eq(excluded(validators::columns::max_commission)), validators::columns::commission .eq(excluded(validators::columns::commission)), - // TODO: maybe metadata can change more often? validators::columns::email .eq(excluded(validators::columns::email)), validators::columns::website @@ -133,6 +132,8 @@ async fn crawling_fn( .eq(excluded(validators::columns::discord_handle)), validators::columns::avatar .eq(excluded(validators::columns::avatar)), + validators::columns::state + .eq(excluded(validators::columns::state)), )) .execute(transaction_conn) .context("Failed to update validators in db")?; diff --git a/pos/src/services/namada.rs b/pos/src/services/namada.rs index 691fac89..f394d89a 100644 --- a/pos/src/services/namada.rs +++ b/pos/src/services/namada.rs @@ -4,7 +4,7 @@ use namada_core::storage::Epoch as NamadaSdkEpoch; use namada_sdk::rpc; use shared::block::Epoch; use shared::id::Id; -use shared::validator::{Validator, ValidatorSet}; +use shared::validator::{Validator, ValidatorSet, ValidatorState}; use tendermint_rpc::HttpClient; pub async fn get_validator_set_at_epoch( @@ -47,8 +47,19 @@ pub async fn get_validator_set_at_epoch( }) }; - let (voting_power, commission_pair) = - futures::try_join!(voting_power_fut, commission_fut)?; + let validator_state = async { + rpc::get_validator_state(client, &address, Some(namada_epoch)) + .await + .with_context(|| { + format!( + "Failed to query validator {address} \ + state" + ) + }) + }; + + let (voting_power, commission_pair, validator_state) = + futures::try_join!(voting_power_fut, commission_fut, validator_state)?; let commission = commission_pair .commission_rate .expect("Commission rate has to exist") @@ -57,6 +68,7 @@ pub async fn get_validator_set_at_epoch( .max_commission_change_per_epoch .expect("Max commission rate change has to exist") .to_string(); + let validator_state = validator_state.0.map(ValidatorState::from).unwrap_or(ValidatorState::Unknown); anyhow::Ok(Validator { address: Id::Account(address.to_string()), @@ -69,6 +81,7 @@ pub async fn get_validator_set_at_epoch( website: None, discord_handler: None, avatar: None, + state: validator_state }) }) .buffer_unordered(100) diff --git a/shared/Cargo.toml b/shared/Cargo.toml index e6d26563..c1cae3d8 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -21,6 +21,7 @@ futures-util.workspace = true futures.workspace = true namada_core.workspace = true namada_governance.workspace = true +namada_proof_of_stake.workspace = true namada_ibc.workspace = true namada_sdk.workspace = true namada_tx.workspace = true diff --git a/shared/src/validator.rs b/shared/src/validator.rs index 7b5a97e3..816267f4 100644 --- a/shared/src/validator.rs +++ b/shared/src/validator.rs @@ -1,12 +1,40 @@ use fake::faker::company::en::{CatchPhase, CompanyName}; use fake::faker::internet::en::{DomainSuffix, SafeEmail, Username}; use fake::Fake; +use namada_proof_of_stake::types::ValidatorState as NamadaValidatorState; +use rand::distributions::{Distribution, Standard}; use crate::block::Epoch; use crate::id::Id; pub type VotingPower = String; +#[derive(Debug, Clone)] +pub enum ValidatorState { + Consensus, + BelowCapacity, + BelowThreshold, + Inactive, + Jailed, + Unknown, +} + +impl From for ValidatorState { + fn from(value: NamadaValidatorState) -> Self { + match value { + NamadaValidatorState::Consensus => ValidatorState::Consensus, + NamadaValidatorState::BelowCapacity => { + ValidatorState::BelowCapacity + } + NamadaValidatorState::BelowThreshold => { + ValidatorState::BelowThreshold + } + NamadaValidatorState::Inactive => ValidatorState::Inactive, + NamadaValidatorState::Jailed => ValidatorState::Jailed, + } + } +} + #[derive(Debug, Clone)] pub struct ValidatorSet { pub validators: Vec, @@ -25,6 +53,7 @@ pub struct Validator { pub website: Option, pub discord_handler: Option, pub avatar: Option, + pub state: ValidatorState, } #[derive(Debug, Clone)] @@ -68,6 +97,22 @@ impl Validator { website, discord_handler, avatar: Some("https://picsum.photos/200/300".to_string()), + state: rand::random(), + } + } +} + +impl Distribution for Standard { + fn sample( + &self, + rng: &mut R, + ) -> ValidatorState { + match rng.gen_range(0..=5) { + 0 => ValidatorState::Consensus, + 1 => ValidatorState::Inactive, + 2 => ValidatorState::Jailed, + 3 => ValidatorState::BelowCapacity, + _ => ValidatorState::BelowThreshold, } } } diff --git a/swagger.yml b/swagger.yml index 2f5ce1e5..d08f631c 100644 --- a/swagger.yml +++ b/swagger.yml @@ -38,6 +38,49 @@ paths: $ref: '#/components/schemas/Validator' pagination: $ref: '#/components/schemas/Pagination' + type: object + properties: + page: + type: integer + minimum: 0 + per_page: + type: integer + minimum: 0 + total_pages: + type: integer + minimum: 0 + total_items: + type: integer + minimum: 0 + /api/v1/pos/my-validator: + get: + responses: + '200': + description: Given a list of address return a list of a validator for which those addresses have delegations + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Validator' + pagination: + type: object + properties: + page: + type: integer + minimum: 0 + per_page: + type: integer + minimum: 0 + total_pages: + type: integer + minimum: 0 + total_items: + type: integer + minimum: 0 /api/v1/pos/reward/{address}: get: summary: Get all the rewards for an address diff --git a/webserver/src/app.rs b/webserver/src/app.rs index 41c231a2..0cb9be48 100644 --- a/webserver/src/app.rs +++ b/webserver/src/app.rs @@ -45,6 +45,10 @@ impl ApplicationServer { Router::new() .route("/pos/validator", get(pos_handlers::get_validators)) + .route( + "/pos/my-validator", + get(pos_handlers::get_my_validators), + ) .route("/pos/bond/:address", get(pos_handlers::get_bonds)) .route("/pos/unbond/:address", get(pos_handlers::get_unbonds)) .route( diff --git a/webserver/src/dto/pos.rs b/webserver/src/dto/pos.rs index 2a54b184..7f9cec46 100644 --- a/webserver/src/dto/pos.rs +++ b/webserver/src/dto/pos.rs @@ -1,8 +1,44 @@ use serde::{Deserialize, Serialize}; use validator::Validate; +#[derive(Clone, Serialize, Deserialize)] +pub enum ValidatorStateDto { + Consensus, + BelowCapacity, + BelowThreshold, + Inactive, + Jailed, + Unknown, +} + +impl ValidatorStateDto { + pub fn all() -> Vec { + [ + Self::Consensus, + Self::BelowCapacity, + Self::BelowThreshold, + Self::Inactive, + Self::Jailed, + Self::Unknown, + ] + .to_vec() + } +} + #[derive(Clone, Serialize, Deserialize, Validate)] pub struct PoSQueryParams { #[validate(range(min = 1, max = 10000))] pub page: Option, + pub state: Option>, +} + +#[derive(Clone, Serialize, Deserialize, Validate)] +pub struct MyValidatorQueryParams { + #[validate(range(min = 1, max = 10000))] + pub page: Option, + #[validate(length( + min = 1, + message = "Address query parameter cannot be empty" + ))] + pub addresses: Vec, } diff --git a/webserver/src/handler/pos.rs b/webserver/src/handler/pos.rs index 336a6aa3..96319b46 100644 --- a/webserver/src/handler/pos.rs +++ b/webserver/src/handler/pos.rs @@ -4,7 +4,9 @@ use axum::Json; use axum_macros::debug_handler; use axum_trace_id::TraceId; -use crate::dto::pos::PoSQueryParams; +use crate::dto::pos::{ + MyValidatorQueryParams, PoSQueryParams, ValidatorStateDto, +}; use crate::error::api::ApiError; use crate::response::pos::{ Bond, Reward, TotalVotingPower, Unbond, ValidatorWithId, Withdraw, @@ -20,8 +22,28 @@ pub async fn get_validators( State(state): State, ) -> Result>>, ApiError> { let page = query.page.unwrap_or(1); + let states = query.state.unwrap_or_else(ValidatorStateDto::all); let (validators, total_validators) = - state.pos_service.get_all_validators(page).await?; + state.pos_service.get_all_validators(page, states).await?; + + let response = PaginatedResponse::new(validators, page, total_validators); + Ok(Json(response)) +} + +#[debug_handler] +pub async fn get_my_validators( + _trace_id: TraceId, + _headers: HeaderMap, + Query(query): Query, + State(state): State, +) -> Result>>, ApiError> { + // TODO: validate that query.address contains valid bech32m encoded + // addresses + let page = query.page.unwrap_or(1); + let (validators, total_validators) = state + .pos_service + .get_my_validators(page, query.addresses) + .await?; let response = PaginatedResponse::new(validators, page, total_validators); Ok(Json(response)) diff --git a/webserver/src/repository/pos.rs b/webserver/src/repository/pos.rs index f9cc47af..46f35ea8 100644 --- a/webserver/src/repository/pos.rs +++ b/webserver/src/repository/pos.rs @@ -8,7 +8,7 @@ use orm::bond::BondDb; use orm::pos_rewards::PoSRewardDb; use orm::schema::{bonds, pos_rewards, unbonds, validators}; use orm::unbond::UnbondDb; -use orm::validators::ValidatorDb; +use orm::validators::{ValidatorDb, ValidatorStateDb}; use super::utils::Paginate; use crate::appstate::AppState; @@ -25,6 +25,13 @@ pub trait PosRepositoryTrait { async fn find_all_validators( &self, page: i64, + states: Vec, + ) -> Result<(Vec, i64), String>; + + async fn find_validators_by_bond_addresses( + &self, + page: i64, + addreses: Vec, ) -> Result<(Vec, i64), String>; async fn find_validator_by_id( @@ -65,11 +72,33 @@ impl PosRepositoryTrait for PosRepository { async fn find_all_validators( &self, page: i64, + states: Vec, + ) -> Result<(Vec, i64), String> { + let conn = self.app_state.get_db_connection().await; + + conn.interact(move |conn| { + validators::table + .filter(validators::dsl::state.eq_any(states)) + .select(ValidatorDb::as_select()) + .paginate(page) + .load_and_count_pages(conn) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string()) + } + + async fn find_validators_by_bond_addresses( + &self, + page: i64, + addreses: Vec, ) -> Result<(Vec, i64), String> { let conn = self.app_state.get_db_connection().await; conn.interact(move |conn| { validators::table + .inner_join(bonds::table) + .filter(bonds::dsl::address.eq_any(addreses)) .select(ValidatorDb::as_select()) .paginate(page) .load_and_count_pages(conn) diff --git a/webserver/src/service/pos.rs b/webserver/src/service/pos.rs index 7487183a..25792771 100644 --- a/webserver/src/service/pos.rs +++ b/webserver/src/service/pos.rs @@ -1,5 +1,8 @@ +use orm::validators::ValidatorStateDb; + use super::utils::raw_amount_to_nam; use crate::appstate::AppState; +use crate::dto::pos::ValidatorStateDto; use crate::error::pos::PoSError; use crate::repository::pos::{PosRepository, PosRepositoryTrait}; use crate::response::pos::{Bond, Reward, Unbond, ValidatorWithId, Withdraw}; @@ -19,10 +22,46 @@ impl PosService { pub async fn get_all_validators( &self, page: u64, + states: Vec, + ) -> Result<(Vec, u64), PoSError> { + let validator_states = states + .into_iter() + .map(|state| match state { + ValidatorStateDto::Consensus => ValidatorStateDb::Consensus, + ValidatorStateDto::BelowCapacity => { + ValidatorStateDb::BelowCapacity + } + ValidatorStateDto::BelowThreshold => { + ValidatorStateDb::BelowThreshold + } + ValidatorStateDto::Inactive => ValidatorStateDb::Inactive, + ValidatorStateDto::Jailed => ValidatorStateDb::Jailed, + ValidatorStateDto::Unknown => ValidatorStateDb::Unknown, + }) + .collect(); + let (db_validators, total_items) = self + .pos_repo + .find_all_validators(page as i64, validator_states) + .await + .map_err(PoSError::Database)?; + + Ok(( + db_validators + .into_iter() + .map(ValidatorWithId::from) + .collect(), + total_items as u64, + )) + } + + pub async fn get_my_validators( + &self, + page: u64, + addresses: Vec, ) -> Result<(Vec, u64), PoSError> { let (db_validators, total_items) = self .pos_repo - .find_all_validators(page as i64) + .find_validators_by_bond_addresses(page as i64, addresses) .await .map_err(PoSError::Database)?; @@ -39,6 +78,7 @@ impl PosService { &self, address: String, ) -> Result, PoSError> { + // TODO: could optimize and make a single query let db_bonds = self .pos_repo .find_bonds_by_address(address) @@ -76,6 +116,7 @@ impl PosService { &self, address: String, ) -> Result, PoSError> { + // TODO: could optimize and make a single query let db_unbonds = self .pos_repo .find_unbonds_by_address(address) @@ -113,6 +154,7 @@ impl PosService { address: String, current_epoch: u64, ) -> Result, PoSError> { + // TODO: could optimize and make a single query let db_unbonds = self .pos_repo .find_withdraws_by_address(address, current_epoch as i32) @@ -149,6 +191,7 @@ impl PosService { &self, address: String, ) -> Result, PoSError> { + // TODO: could optimize and make a single query let db_rewards = self .pos_repo .find_rewards_by_address(address)