diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 98fba837c93..6f0c9e4a569 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -9,6 +9,7 @@ Arbritrary asyncio asyncpg auditability +bech bkioshn bluefireteam BROTLI diff --git a/catalyst-gateway/Cargo.toml b/catalyst-gateway/Cargo.toml index 4f43e1300e1..6b88e280721 100644 --- a/catalyst-gateway/Cargo.toml +++ b/catalyst-gateway/Cargo.toml @@ -16,19 +16,14 @@ repository = "https://github.com/input-output-hk/catalyst-voices" license = "MIT OR Apache-2.0" [workspace.dependencies] - clap = "4" - tracing = "0.1.37" tracing-subscriber = "0.3.16" - serde = "1.0" serde_json = "1.0" - poem = "2.0.0" poem-openapi = "4.0.0" poem-extensions = "0.8.0" - prometheus = "0.13.0" cryptoxide = "0.4.4" uuid = "1" @@ -37,25 +32,16 @@ panic-message = "0.3" cpu-time = "1.0" ulid = "1.0.1" rust-embed = "8" - url = "2.4.1" - thiserror = "1.0" - chrono = "0.4" - async-trait = "0.1.64" - rust_decimal = "1.29" - bb8 = "0.8.1" bb8-postgres = "0.8.1" tokio-postgres = "0.7.10" - tokio = "1" - dotenvy = "0.15" - local-ip-address = "0.5.7" gethostname = "0.4.3" diff --git a/catalyst-gateway/bin/src/event_db/follower.rs b/catalyst-gateway/bin/src/event_db/follower.rs index e5eb51efbbc..26ceb9730dc 100644 --- a/catalyst-gateway/bin/src/event_db/follower.rs +++ b/catalyst-gateway/bin/src/event_db/follower.rs @@ -1,12 +1,11 @@ //! Follower Queries use cardano_chain_follower::Network; -use chrono::TimeZone; use crate::event_db::{Error, EventDB}; /// Block time -pub type BlockTime = i64; +pub type BlockTime = chrono::DateTime; /// Slot pub type SlotNumber = i64; /// Epoch @@ -15,8 +14,6 @@ pub type EpochNumber = i64; pub type BlockHash = String; /// Unique follower id pub type MachineId = String; -/// Time when a follower last indexed -pub type LastUpdate = chrono::DateTime; impl EventDB { /// Index follower block stream @@ -26,8 +23,6 @@ impl EventDB { ) -> Result<(), Error> { let conn = self.pool.get().await?; - let timestamp: chrono::DateTime = chrono::Utc.timestamp_nanos(block_time); - let network = match network { Network::Mainnet => "mainnet".to_string(), Network::Preview => "preview".to_string(), @@ -42,7 +37,7 @@ impl EventDB { &slot_no, &network, &epoch_no, - ×tamp, + &block_time, &hex::decode(block_hash).map_err(|e| Error::DecodeHex(e.to_string()))?, ], ) @@ -55,7 +50,7 @@ impl EventDB { /// Start follower from where previous follower left off. pub(crate) async fn last_updated_metadata( &self, network: String, - ) -> Result<(SlotNumber, BlockHash, LastUpdate), Error> { + ) -> Result<(SlotNumber, BlockHash, BlockTime), Error> { let conn = self.pool.get().await?; let rows = conn @@ -72,16 +67,16 @@ impl EventDB { return Err(Error::NoLastUpdateMetadata("No metadata".to_string())); }; - let slot_no: SlotNumber = match row.try_get("slot_no") { + let slot_no = match row.try_get("slot_no") { Ok(slot) => slot, Err(e) => return Err(Error::NoLastUpdateMetadata(e.to_string())), }; - let block_hash: BlockHash = match row.try_get::<_, Vec>("block_hash") { + let block_hash = match row.try_get::<_, Vec>("block_hash") { Ok(block_hash) => hex::encode(block_hash), Err(e) => return Err(Error::NoLastUpdateMetadata(e.to_string())), }; - let last_updated: LastUpdate = match row.try_get("ended") { + let last_updated = match row.try_get("ended") { Ok(last_updated) => last_updated, Err(e) => return Err(Error::NoLastUpdateMetadata(e.to_string())), }; @@ -92,7 +87,7 @@ impl EventDB { /// Mark point in time where the last follower finished indexing in order for future /// followers to pick up from this point pub(crate) async fn refresh_last_updated( - &self, last_updated: LastUpdate, slot_no: SlotNumber, block_hash: BlockHash, + &self, last_updated: BlockTime, slot_no: SlotNumber, block_hash: BlockHash, network: Network, machine_id: &MachineId, ) -> Result<(), Error> { let conn = self.pool.get().await?; diff --git a/catalyst-gateway/bin/src/event_db/utxo.rs b/catalyst-gateway/bin/src/event_db/utxo.rs index c059b405e47..93a46f728fb 100644 --- a/catalyst-gateway/bin/src/event_db/utxo.rs +++ b/catalyst-gateway/bin/src/event_db/utxo.rs @@ -3,7 +3,10 @@ use cardano_chain_follower::Network; use pallas::ledger::traverse::MultiEraTx; -use super::follower::SlotNumber; +use super::{ + follower::{BlockTime, SlotNumber}, + voter_registration::StakeCredential, +}; use crate::{ event_db::{ Error::{self, SqlTypeConversionFailure}, @@ -15,9 +18,12 @@ use crate::{ }, }; +/// Stake amount. +pub(crate) type StakeAmount = i64; + impl EventDB { /// Index utxo data - pub async fn index_utxo_data( + pub(crate) async fn index_utxo_data( &self, txs: Vec>, slot_no: SlotNumber, network: Network, ) -> Result<(), Error> { let conn = self.pool.get().await?; @@ -57,9 +63,7 @@ impl EventDB { let _rows = conn .query( - include_str!( - "../../../event-db/queries/follower/utxo_index_utxo_query.sql" - ), + include_str!("../../../event-db/queries/utxo/insert_utxo.sql"), &[ &i32::try_from(index).map_err(|e| { Error::NotFound( @@ -90,7 +94,7 @@ impl EventDB { } /// Index txn metadata - pub async fn index_txn_data( + pub(crate) async fn index_txn_data( &self, tx_id: &[u8], slot_no: SlotNumber, network: Network, ) -> Result<(), Error> { let conn = self.pool.get().await?; @@ -104,11 +108,45 @@ impl EventDB { let _rows = conn .query( - include_str!("../../../event-db/queries/follower/utxo_txn_index.sql"), + include_str!("../../../event-db/queries/utxo/insert_txn_index.sql"), &[&tx_id, &slot_no, &network], ) .await?; Ok(()) } + + /// Get total utxo amount + #[allow(dead_code)] + pub(crate) async fn total_utxo_amount( + &self, stake_credential: StakeCredential<'_>, network: Network, date_time: BlockTime, + ) -> Result<(StakeAmount, SlotNumber, BlockTime), Error> { + let conn = self.pool.get().await?; + + let network = match network { + Network::Mainnet => "mainnet".to_string(), + Network::Preview => "preview".to_string(), + Network::Preprod => "preprod".to_string(), + Network::Testnet => "testnet".to_string(), + }; + + let row = conn + .query_one( + include_str!("../../../event-db/queries/utxo/select_total_utxo_amount.sql"), + &[&stake_credential, &network, &date_time], + ) + .await?; + + // Aggregate functions as SUM and MAX return NULL if there are no rows, so we need to + // check for it. + // https://www.postgresql.org/docs/8.2/functions-aggregate.html + if let Some(amount) = row.try_get("total_utxo_amount")? { + let slot_number = row.try_get("slot_no")?; + let block_time = row.try_get("block_time")?; + + Ok((amount, slot_number, block_time)) + } else { + Err(Error::NotFound("Cannot find total utxo amount".to_string())) + } + } } diff --git a/catalyst-gateway/bin/src/event_db/voter_registration.rs b/catalyst-gateway/bin/src/event_db/voter_registration.rs index 3d0b169c094..b472af8b7d0 100644 --- a/catalyst-gateway/bin/src/event_db/voter_registration.rs +++ b/catalyst-gateway/bin/src/event_db/voter_registration.rs @@ -5,17 +5,17 @@ use super::{Error, EventDB}; /// Transaction id pub(crate) type TxId = String; /// Stake credential -pub(crate) type StakeCredential = Box<[u8]>; +pub(crate) type StakeCredential<'a> = &'a [u8]; /// Public voting key -pub(crate) type PublicVotingKey = Box<[u8]>; +pub(crate) type PublicVotingKey<'a> = &'a [u8]; /// Payment address -pub(crate) type PaymentAddress = Box<[u8]>; +pub(crate) type PaymentAddress<'a> = &'a [u8]; /// Nonce pub(crate) type Nonce = i64; /// Metadata 61284 -pub(crate) type Metadata61284 = Box<[u8]>; +pub(crate) type Metadata61284<'a> = &'a [u8]; /// Metadata 61285 -pub(crate) type Metadata61285 = Box<[u8]>; +pub(crate) type Metadata61285<'a> = &'a [u8]; /// Stats pub(crate) type Stats = Option; @@ -23,9 +23,10 @@ impl EventDB { /// Inserts voter registration data, replacing any existing data. #[allow(dead_code, clippy::too_many_arguments)] async fn insert_voter_registration( - &self, tx_id: TxId, stake_credential: StakeCredential, public_voting_key: PublicVotingKey, - payment_address: PaymentAddress, nonce: Nonce, metadata_61284: Metadata61284, - metadata_61285: Metadata61285, valid: bool, stats: Stats, + &self, tx_id: TxId, stake_credential: StakeCredential<'_>, + public_voting_key: PublicVotingKey<'_>, payment_address: PaymentAddress<'_>, nonce: Nonce, + metadata_61284: Metadata61284<'_>, metadata_61285: Metadata61285<'_>, valid: bool, + stats: Stats, ) -> Result<(), Error> { let conn = self.pool.get().await?; @@ -36,12 +37,12 @@ impl EventDB { ), &[ &hex::decode(tx_id).map_err(|e| Error::DecodeHex(e.to_string()))?, - &stake_credential.as_ref(), - &public_voting_key.as_ref(), - &payment_address.as_ref(), + &stake_credential, + &public_voting_key, + &payment_address, &nonce, - &metadata_61284.as_ref(), - &metadata_61285.as_ref(), + &metadata_61284, + &metadata_61285, &valid, &stats, ], diff --git a/catalyst-gateway/bin/src/follower.rs b/catalyst-gateway/bin/src/follower.rs index c08ebaaab93..e668760c1ce 100644 --- a/catalyst-gateway/bin/src/follower.rs +++ b/catalyst-gateway/bin/src/follower.rs @@ -8,13 +8,14 @@ use async_recursion::async_recursion; use cardano_chain_follower::{ network_genesis_values, ChainUpdate, Follower, FollowerConfigBuilder, Network, Point, }; +use chrono::TimeZone; use tokio::{task::JoinHandle, time}; use tracing::{error, info}; use crate::{ event_db::{ config::{FollowerMeta, NetworkMeta}, - follower::{BlockHash, LastUpdate, MachineId, SlotNumber}, + follower::{BlockHash, BlockTime, MachineId, SlotNumber}, EventDB, }, util::valid_era, @@ -150,7 +151,7 @@ async fn spawn_followers( /// it left off. If there was no previous follower, start indexing from genesis point. async fn find_last_update_point( db: Arc, network: &String, -) -> Result<(Option, Option, Option), Box> { +) -> Result<(Option, Option, Option), Box> { let (slot_no, block_hash, last_updated) = match db.last_updated_metadata(network.to_string()).await { Ok((slot_no, block_hash, last_updated)) => { @@ -214,7 +215,7 @@ async fn init_follower( }; let wallclock = match block.wallclock(&genesis_values).try_into() { - Ok(time) => time, + Ok(time) => chrono::Utc.timestamp_nanos(time), Err(err) => { error!("Cannot parse wall time from block {:?} - skip..", err); continue; diff --git a/catalyst-gateway/bin/src/service/api/mod.rs b/catalyst-gateway/bin/src/service/api/mod.rs index b99aa95db89..53875cba7c8 100644 --- a/catalyst-gateway/bin/src/service/api/mod.rs +++ b/catalyst-gateway/bin/src/service/api/mod.rs @@ -11,11 +11,13 @@ use local_ip_address::list_afinet_netifas; use poem_openapi::{ContactObject, LicenseObject, OpenApiService, ServerObject}; use test_endpoints::TestApi; +use self::utxo::UTXOApi; use crate::settings::{DocsSettings, API_URL_PREFIX}; mod health; mod legacy; mod test_endpoints; +mod utxo; /// The name of the API const API_TITLE: &str = "Catalyst Gateway"; @@ -58,11 +60,12 @@ const TERMS_OF_SERVICE: &str = /// Create the `OpenAPI` definition pub(crate) fn mk_api( hosts: Vec, settings: &DocsSettings, -) -> OpenApiService<(TestApi, HealthApi, LegacyApi), ()> { +) -> OpenApiService<(TestApi, HealthApi, UTXOApi, LegacyApi), ()> { let mut service = OpenApiService::new( ( TestApi, HealthApi, + UTXOApi, (legacy::RegistrationApi, legacy::V0Api, legacy::V1Api), ), API_TITLE, diff --git a/catalyst-gateway/bin/src/service/api/utxo/mod.rs b/catalyst-gateway/bin/src/service/api/utxo/mod.rs new file mode 100644 index 00000000000..e6bf510bd13 --- /dev/null +++ b/catalyst-gateway/bin/src/service/api/utxo/mod.rs @@ -0,0 +1,69 @@ +//! Cardano UTXO endpoints + +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use poem::web::Data; +use poem_openapi::{ + param::{Path, Query}, + OpenApi, +}; + +use crate::{ + service::{ + common::{ + objects::{cardano_address::CardanoStakeAddress, network::Network}, + tags::ApiTags, + }, + utilities::middleware::schema_validation::schema_version_validation, + }, + state::State, +}; + +mod staked_ada_get; + +/// Cardano UTXO API Endpoints +pub(crate) struct UTXOApi; + +#[OpenApi(prefix_path = "/utxo", tag = "ApiTags::Utxo")] +impl UTXOApi { + #[oai( + path = "/staked_ada/:stake_address", + method = "get", + operation_id = "stakedAdaAmountGet", + transform = "schema_version_validation", + // TODO: https://github.com/input-output-hk/catalyst-voices/issues/330 + deprecated = true + )] + /// Get staked ada amount. + /// + /// This endpoint returns the total Cardano's staked ada amount to the corresponded + /// user's stake address. + /// + /// ## Responses + /// * 200 OK - Returns the staked ada amount. + /// * 400 Bad Request. + /// * 404 Not Found. + /// * 500 Server Error - If anything within this function fails unexpectedly. + /// * 503 Service Unavailable - Service is not ready, requests to other + /// endpoints should not be sent until the service becomes ready. + async fn staked_ada_get( + &self, data: Data<&Arc>, + /// The stake address of the user. + /// Should a valid Bech32 encoded address followed by the https://cips.cardano.org/cip/CIP-19/#stake-addresses. + stake_address: Path, + /// Cardano network type. + /// If omitted network type is identified from the stake address. + /// If specified it must be correspondent to the network type encoded in the stake + /// address. + /// As `preprod` and `preview` network types in the stake address encoded as a + /// `testnet`, to specify `preprod` or `preview` network type use this + /// query parameter. + network: Query>, + /// Date time at which the staked ada amount should be calculated. + /// If omitted current date time is used. + date_time: Query>>, + ) -> staked_ada_get::AllResponses { + staked_ada_get::endpoint(&data, stake_address.0, network.0, date_time.0).await + } +} diff --git a/catalyst-gateway/bin/src/service/api/utxo/staked_ada_get.rs b/catalyst-gateway/bin/src/service/api/utxo/staked_ada_get.rs new file mode 100644 index 00000000000..097149a8b31 --- /dev/null +++ b/catalyst-gateway/bin/src/service/api/utxo/staked_ada_get.rs @@ -0,0 +1,109 @@ +//! Implementation of the GET `/utxo/staked_ada` endpoint + +use chrono::{DateTime, Utc}; +use poem_extensions::{ + response, + UniResponse::{T200, T400, T404, T500, T503}, +}; +use poem_openapi::{payload::Json, types::ToJSON}; + +use crate::{ + cli::Error, + event_db::error::Error as DBError, + service::common::{ + objects::{ + cardano_address::CardanoStakeAddress, network::Network, stake_amount::StakeInfo, + }, + responses::{ + resp_2xx::OK, + resp_4xx::{ApiValidationError, NotFound}, + resp_5xx::{server_error, ServerError, ServiceUnavailable}, + }, + }, + state::{SchemaVersionStatus, State}, +}; + +/// # All Responses +pub(crate) type AllResponses = response! { + 200: OK>, + 400: ApiValidationError, + 404: NotFound, + 500: ServerError, + 503: ServiceUnavailable, +}; + +/// # GET `/utxo/staked_ada` +#[allow(clippy::unused_async)] +pub(crate) async fn endpoint( + state: &State, stake_address: CardanoStakeAddress, provided_network: Option, + date_time: Option>, +) -> AllResponses { + match state.event_db() { + Ok(event_db) => { + let date_time = date_time.unwrap_or_else(Utc::now); + let stake_credential = stake_address.payload().as_hash().as_ref(); + + // check the provided network type with the encoded inside the stake address + let network = match stake_address.network() { + pallas::ledger::addresses::Network::Mainnet => { + if let Some(network) = provided_network { + if !matches!(&network, Network::Mainnet) { + return T400(ApiValidationError::new(format!( + "Provided network type {} does not match stake address network type Mainnet", network.to_json_string() + ))); + } + } + Network::Mainnet + }, + pallas::ledger::addresses::Network::Testnet => { + // the preprod and preview network types are encoded as `testnet` in the stake + // address, so here we are checking if the `provided_network` type matches the + // one, and if not - we return an error. + // if the `provided_network` omitted - we return the `testnet` network type + if let Some(network) = provided_network { + if !matches!( + network, + Network::Testnet | Network::Preprod | Network::Preview + ) { + return T400(ApiValidationError::new(format!( + "Provided network type {} does not match stake address network type Testnet", network.to_json_string() + ))); + } + network + } else { + Network::Testnet + } + }, + pallas::ledger::addresses::Network::Other(x) => { + return T400(ApiValidationError::new(format!("Unknown network type {x}"))); + }, + }; + + // get the total utxo amount from the database + match event_db + .total_utxo_amount(stake_credential, network.into(), date_time) + .await + { + Ok((amount, slot_number, block_time)) => { + T200(OK(Json(StakeInfo { + amount, + slot_number, + block_time, + }))) + }, + Err(DBError::NotFound(_)) => T404(NotFound), + Err(err) => T500(server_error!("{}", err.to_string())), + } + }, + Err(Error::EventDb(DBError::MismatchedSchema { was, expected })) => { + tracing::error!( + expected = expected, + current = was, + "DB schema version status mismatch" + ); + state.set_schema_version_status(SchemaVersionStatus::Mismatch); + T503(ServiceUnavailable) + }, + Err(err) => T500(server_error!("{}", err.to_string())), + } +} diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano_address.rs b/catalyst-gateway/bin/src/service/common/objects/cardano_address.rs new file mode 100644 index 00000000000..131bd3b77d5 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/cardano_address.rs @@ -0,0 +1,83 @@ +//! Defines API schemas of Cardano address types. + +use std::ops::Deref; + +use pallas::ledger::addresses::{Address, StakeAddress}; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef, Registry}, + types::{ParseError, ParseFromParameter, ParseResult, Type}, +}; + +/// Cardano stake address of the user. +/// Should a valid Bech32 encoded stake address followed by the `https://cips.cardano.org/cip/CIP-19/#stake-addresses.` +#[derive(Debug)] +pub(crate) struct CardanoStakeAddress(StakeAddress); + +impl CardanoStakeAddress { + /// Creates a `CardanoStakeAddress` schema definition. + fn schema() -> MetaSchema { + let mut schema = MetaSchema::new("string"); + schema.title = Some("CardanoStakeAddress".to_string()); + schema.description = Some("The stake address of the user. Should a valid Bech32 encoded address followed by the https://cips.cardano.org/cip/CIP-19/#stake-addresses."); + schema.example = Some(serde_json::Value::String( + // cspell: disable + "stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw".to_string(), + // cspell: enable + )); + schema.max_length = Some(64); + schema.pattern = Some("(stake|stake_test)1[a,c-h,j-n,p-z,0,2-9]{53}".to_string()); + schema + } +} + +impl Deref for CardanoStakeAddress { + type Target = StakeAddress; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Type for CardanoStakeAddress { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "CardanoStakeAddress".into() + } + + fn schema_ref() -> MetaSchemaRef { + MetaSchemaRef::Reference(Self::name().to_string()) + } + + fn register(registry: &mut Registry) { + registry.create_schema::(Self::name().to_string(), |_| Self::schema()); + } + + fn as_raw_value(&self) -> Option<&Self::RawValueType> { + Some(self) + } + + fn raw_element_iter<'a>( + &'a self, + ) -> Box + 'a> { + Box::new(self.as_raw_value().into_iter()) + } +} + +impl ParseFromParameter for CardanoStakeAddress { + fn parse_from_parameter(param: &str) -> ParseResult { + // prefix checks + if !param.starts_with("stake") && !param.starts_with("stake_test") { + return Err(ParseError::custom("Invalid Cardano stake address")); + } + let address = Address::from_bech32(param).map_err(|e| ParseError::custom(e.to_string()))?; + if let Address::Stake(stake_address) = address { + Ok(Self(stake_address)) + } else { + Err(ParseError::custom("Invalid Cardano stake address")) + } + } +} diff --git a/catalyst-gateway/bin/src/service/common/objects/mod.rs b/catalyst-gateway/bin/src/service/common/objects/mod.rs index 6eebf89230a..f967ccb7e6b 100644 --- a/catalyst-gateway/bin/src/service/common/objects/mod.rs +++ b/catalyst-gateway/bin/src/service/common/objects/mod.rs @@ -1,3 +1,6 @@ //! This module contains common and re-usable objects. +pub(crate) mod cardano_address; pub(crate) mod legacy; +pub(crate) mod network; +pub(crate) mod stake_amount; diff --git a/catalyst-gateway/bin/src/service/common/objects/network.rs b/catalyst-gateway/bin/src/service/common/objects/network.rs new file mode 100644 index 00000000000..fc0ea577cab --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/network.rs @@ -0,0 +1,27 @@ +//! Defines API schemas of Cardano network types. + +use poem_openapi::Enum; + +/// Cardano network type. +#[derive(Enum, Debug)] +pub(crate) enum Network { + /// Cardano mainnet. + Mainnet, + /// Cardano testnet. + Testnet, + /// Cardano preprod. + Preprod, + /// Cardano preview. + Preview, +} + +impl From for cardano_chain_follower::Network { + fn from(value: Network) -> Self { + match value { + Network::Mainnet => Self::Mainnet, + Network::Testnet => Self::Testnet, + Network::Preprod => Self::Preprod, + Network::Preview => Self::Preview, + } + } +} diff --git a/catalyst-gateway/bin/src/service/common/objects/stake_amount.rs b/catalyst-gateway/bin/src/service/common/objects/stake_amount.rs new file mode 100644 index 00000000000..94298e30538 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/stake_amount.rs @@ -0,0 +1,37 @@ +//! Defines API schemas of stake amount type. + +use chrono::Utc; +use poem_openapi::{types::Example, Object}; + +use crate::event_db::{ + follower::{BlockTime, SlotNumber}, + utxo::StakeAmount, +}; + +/// User's cardano stake info. +#[derive(Object)] +#[oai(example = true)] +pub(crate) struct StakeInfo { + /// Stake amount. + // TODO(bkioshn): https://github.com/input-output-hk/catalyst-voices/issues/239 + #[oai(validator(minimum(value = "0"), maximum(value = "4294967295")))] + pub(crate) amount: StakeAmount, + + /// Slot number. + // TODO(bkioshn): https://github.com/input-output-hk/catalyst-voices/issues/239 + #[oai(validator(minimum(value = "0"), maximum(value = "4294967295")))] + pub(crate) slot_number: SlotNumber, + + /// Block date time. + pub(crate) block_time: BlockTime, +} + +impl Example for StakeInfo { + fn example() -> Self { + Self { + amount: 1, + slot_number: 5, + block_time: Utc::now(), + } + } +} diff --git a/catalyst-gateway/bin/src/service/common/responses/resp_4xx.rs b/catalyst-gateway/bin/src/service/common/responses/resp_4xx.rs index 2e9e2e027e8..c2317d79c35 100644 --- a/catalyst-gateway/bin/src/service/common/responses/resp_4xx.rs +++ b/catalyst-gateway/bin/src/service/common/responses/resp_4xx.rs @@ -15,6 +15,13 @@ pub(crate) struct BadRequest(T); /// It has failed to pass validation, as specified by the `OpenAPI` schema. pub(crate) struct ApiValidationError(PlainText); +impl ApiValidationError { + /// Create new `ApiValidationError` + pub(crate) fn new(error: String) -> Self { + Self(PlainText(error)) + } +} + #[derive(OneResponse)] #[oai(status = 401)] /// ## Unauthorized diff --git a/catalyst-gateway/bin/src/service/common/tags.rs b/catalyst-gateway/bin/src/service/common/tags.rs index 0e4f13398b8..5075d0c1ea2 100644 --- a/catalyst-gateway/bin/src/service/common/tags.rs +++ b/catalyst-gateway/bin/src/service/common/tags.rs @@ -8,6 +8,8 @@ pub(crate) enum ApiTags { Fragments, /// Health Endpoints Health, + /// UTXO Endpoints + Utxo, /// Information relating to Voter Registration, Delegations and Calculated Voting /// Power. Registration, diff --git a/catalyst-gateway/bin/src/state/mod.rs b/catalyst-gateway/bin/src/state/mod.rs index 4b1aab075b1..c0e073dc247 100644 --- a/catalyst-gateway/bin/src/state/mod.rs +++ b/catalyst-gateway/bin/src/state/mod.rs @@ -52,7 +52,6 @@ impl State { } /// Get the reference to the database connection pool for `EventDB`. - #[allow(dead_code)] pub(crate) fn event_db(&self) -> Result, Error> { let guard = self.schema_version_status_lock(); match *guard { diff --git a/catalyst-gateway/event-db/migrations/V6__registration.sql b/catalyst-gateway/event-db/migrations/V6__registration.sql index 2e86a8a9162..6dbce906c93 100644 --- a/catalyst-gateway/event-db/migrations/V6__registration.sql +++ b/catalyst-gateway/event-db/migrations/V6__registration.sql @@ -3,7 +3,7 @@ -- Title : Role Registration Data --- cspell: words utxo stxo +-- cspell: words utxo -- Configuration Tables -- ------------------------------------------------------------------------------------------------- diff --git a/catalyst-gateway/event-db/queries/follower/utxo_txn_index.sql b/catalyst-gateway/event-db/queries/utxo/insert_txn_index.sql similarity index 100% rename from catalyst-gateway/event-db/queries/follower/utxo_txn_index.sql rename to catalyst-gateway/event-db/queries/utxo/insert_txn_index.sql diff --git a/catalyst-gateway/event-db/queries/follower/utxo_index_utxo_query.sql b/catalyst-gateway/event-db/queries/utxo/insert_utxo.sql similarity index 100% rename from catalyst-gateway/event-db/queries/follower/utxo_index_utxo_query.sql rename to catalyst-gateway/event-db/queries/utxo/insert_utxo.sql diff --git a/catalyst-gateway/event-db/queries/utxo/select_total_utxo_amount.sql b/catalyst-gateway/event-db/queries/utxo/select_total_utxo_amount.sql new file mode 100644 index 00000000000..d962f8f25ac --- /dev/null +++ b/catalyst-gateway/event-db/queries/utxo/select_total_utxo_amount.sql @@ -0,0 +1,26 @@ +-- Select total UTXO's corresponded to the provided stake credential from the given network that have occurred before the given time. +SELECT + SUM(cardano_utxo.value)::BIGINT AS total_utxo_amount, + MAX(cardano_slot_index.slot_no) AS slot_no, + MAX(cardano_slot_index.block_time) AS block_time + +FROM cardano_utxo + +INNER JOIN cardano_txn_index + ON cardano_utxo.tx_id = cardano_txn_index.id + +-- filter out orphaned transactions +INNER JOIN cardano_update_state + ON + cardano_txn_index.slot_no <= cardano_update_state.slot_no + AND cardano_txn_index.network = cardano_update_state.network + +INNER JOIN cardano_slot_index + ON + cardano_txn_index.slot_no = cardano_slot_index.slot_no + AND cardano_txn_index.network = cardano_slot_index.network + +WHERE + cardano_utxo.stake_credential = $1 + AND cardano_txn_index.network = $2 + AND cardano_slot_index.block_time <= $3; diff --git a/catalyst-gateway/tests/.spectral.yml b/catalyst-gateway/tests/.spectral.yml index 8a8c366e86b..5d03e95b9fd 100644 --- a/catalyst-gateway/tests/.spectral.yml +++ b/catalyst-gateway/tests/.spectral.yml @@ -1,4 +1,4 @@ -# References to the rules +# References to the rules # OpenAPI: https://docs.stoplight.io/docs/spectral/4dec24461f3af-open-api-rules#openapi-rules # OWASP Top 10: https://github.com/stoplightio/spectral-owasp-ruleset/blob/v1.4.3/src/ruleset.ts # Documentations: https://github.com/stoplightio/spectral-documentation/blob/v1.3.1/src/ruleset.ts @@ -7,116 +7,120 @@ # Use CDN hosted version for spectral-documentation and spectral-owasp extends: -- 'spectral:oas' -- 'https://unpkg.com/@stoplight/spectral-documentation@1.3.1/dist/ruleset.mjs' -- 'https://unpkg.com/@stoplight/spectral-owasp-ruleset@1.4.3/dist/ruleset.mjs' + - "spectral:oas" + - "https://unpkg.com/@stoplight/spectral-documentation@1.3.1/dist/ruleset.mjs" + - "https://unpkg.com/@stoplight/spectral-owasp-ruleset@1.4.3/dist/ruleset.mjs" aliases: PathItem: - - $.paths[*] + - $.paths[*] OperationObject: - - $.paths[*][get,put,post,delete,options,head,patch,trace] + - $.paths[*][get,put,post,delete,options,head,patch,trace] DescribableObjects: - - $.info - - $.tags[*] - - '#OperationObject' - - '#OperationObject.responses[*]' - - '#PathItem.parameters[?(@ && @.in)]' - - '#OperationObject.parameters[?(@ && @.in)]' + - $.info + - $.tags[*] + - "#OperationObject" + - "#OperationObject.responses[*]" + - "#PathItem.parameters[?(@ && @.in)]" + - "#OperationObject.parameters[?(@ && @.in)]" overrides: -- files: ['*'] - rules: - # Override document description rule - # - No limitations on the characters that can start or end a sentence. - # - Length should be >= 20 characters - # Ref: https://github.com/stoplightio/spectral-documentation/blob/a34ca1b49cbd1ac5a75cfcb93c69d1d77bde341e/src/ruleset.ts#L173 - docs-description: - given: '#DescribableObjects' - then: - - field: 'description' - function: 'truthy' - - field: 'description' - function: 'length' - functionOptions: - min: 20 - - field: 'description' - function: 'pattern' - functionOptions: - # Matches any character that is #, *, uppercase or lowercase letters from A to Z, or digits from 0 to 9 at the beginning of the string. - # with zero or more occurrences of any character except newline. - match: '^[#*A-Za-z0-9].*' + - files: ["*"] + rules: + # Override document description rule + # - No limitations on the characters that can start or end a sentence. + # - Length should be >= 20 characters + # Ref: https://github.com/stoplightio/spectral-documentation/blob/a34ca1b49cbd1ac5a75cfcb93c69d1d77bde341e/src/ruleset.ts#L173 + docs-description: + given: "#DescribableObjects" + then: + - field: "description" + function: "truthy" + - field: "description" + function: "length" + functionOptions: + min: 20 + - field: "description" + function: "pattern" + functionOptions: + # Matches any character that is #, *, uppercase or lowercase letters from A to Z, or digits from 0 to 9 at the beginning of the string. + # with zero or more occurrences of any character except newline. + match: "^[#*A-Za-z0-9].*" - # Severity - # warn: Should be implemented, but is blocked by a technical issue. - # info: Good to be implemented. + # Severity + # warn: Should be implemented, but is blocked by a technical issue. + # info: Good to be implemented. - # Rate limit - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L436 - owasp:api4:2019-rate-limit: warn - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L484 - owasp:api4:2019-rate-limit-responses-429: warn - # Public API - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L305 - owasp:api2:2019-protection-global-safe: info - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L269 - owasp:api2:2019-protection-global-unsafe: info - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L287 - owasp:api2:2019-protection-global-unsafe-strict: info - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L376 - owasp:api3:2019-define-error-responses-401: warn + # Rate limit + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L436 + owasp:api4:2019-rate-limit: warn + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L484 + owasp:api4:2019-rate-limit-responses-429: warn + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L551 + owasp:api4:2019-string-restricted: warn + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L521 + owasp:api4:2019-string-limit: warn + # Public API + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L305 + owasp:api2:2019-protection-global-safe: info + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L269 + owasp:api2:2019-protection-global-unsafe: info + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L287 + owasp:api2:2019-protection-global-unsafe-strict: info + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L376 + owasp:api3:2019-define-error-responses-401: warn - # UUID rules for name containing "id" is ignored - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L102 - owasp:api1:2019-no-numeric-ids: off + # UUID rules for name containing "id" is ignored + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L102 + owasp:api1:2019-no-numeric-ids: off -- files: - - '**#/paths/~1api~1health~1live/get/responses' - - '**#/paths/~1api~1health~1started/get/responses' - - '**#/paths/~1api~1health~1ready/get/responses' - - '**#/paths/~1api~1test~1test~1%7Bid%7D~1test~1%7Baction%7D/post/responses' - - '**#/paths/~1api~1test~1test~1%7Bid%7D~1test~1%7Baction%7D/get/responses' - # Recheck this, already apply validator but does not work "FragmentId" - - '**#/paths/~1api~1v1~1fragments~1statuses/get/parameters/0/schema' - # Recheck this, already apply validator but does not work "AccountId" - - '**#/paths/~1api~1v1~1votes~1plan~1account-votes~1%7Baccount_id%7D/get/parameters/0/schema' - rules: - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L551 - owasp:api4:2019-string-restricted: off + - files: + - "**#/paths/~1api~1health~1live/get/responses" + - "**#/paths/~1api~1health~1started/get/responses" + - "**#/paths/~1api~1health~1ready/get/responses" + - "**#/paths/~1api~1test~1test~1%7Bid%7D~1test~1%7Baction%7D/post/responses" + - "**#/paths/~1api~1test~1test~1%7Bid%7D~1test~1%7Baction%7D/get/responses" + # Recheck this, already apply validator but does not work "FragmentId" + - "**#/paths/~1api~1v1~1fragments~1statuses/get/parameters/0/schema" + # Recheck this, already apply validator but does not work "AccountId" + - "**#/paths/~1api~1v1~1votes~1plan~1account-votes~1%7Baccount_id%7D/get/parameters/0/schema" + rules: + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L551 + owasp:api4:2019-string-restricted: off -- files: - - '**#/paths/~1api~1health~1live/get/responses' - - '**#/paths/~1api~1health~1started/get/responses' - - '**#/paths/~1api~1health~1ready/get/responses' - - '**#/paths/~1api~1v0~1message/post/requestBody/content' - - '**#/paths/~1api~1test~1test~1%7Bid%7D~1test~1%7Baction%7D/post/responses' - - '**#/paths/~1api~1test~1test~1%7Bid%7D~1test~1%7Baction%7D/get/responses' - - '**#/components/schemas/ServerErrorPayload/properties/id' - - '**#/components/schemas/VoterRegistration/properties/as_at' - - '**#/components/schemas/VoterRegistration/properties/last_updated' - # Recheck this, already apply validator but does not work "FragmentId" - - '**#/paths/~1api~1v1~1fragments~1statuses/get/parameters/0/schema' - # Recheck this, already apply validator but does not work "AccountId" - - '**#/paths/~1api~1v1~1votes~1plan~1account-votes~1%7Baccount_id%7D/get/parameters/0/schema' - rules: - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L521 - owasp:api4:2019-string-limit: off + - files: + - "**#/paths/~1api~1health~1live/get/responses" + - "**#/paths/~1api~1health~1started/get/responses" + - "**#/paths/~1api~1health~1ready/get/responses" + - "**#/paths/~1api~1v0~1message/post/requestBody/content" + - "**#/paths/~1api~1test~1test~1%7Bid%7D~1test~1%7Baction%7D/post/responses" + - "**#/paths/~1api~1test~1test~1%7Bid%7D~1test~1%7Baction%7D/get/responses" + - "**#/components/schemas/ServerErrorPayload/properties/id" + - "**#/components/schemas/VoterRegistration/properties/as_at" + - "**#/components/schemas/VoterRegistration/properties/last_updated" + # Recheck this, already apply validator but does not work "FragmentId" + - "**#/paths/~1api~1v1~1fragments~1statuses/get/parameters/0/schema" + # Recheck this, already apply validator but does not work "AccountId" + - "**#/paths/~1api~1v1~1votes~1plan~1account-votes~1%7Baccount_id%7D/get/parameters/0/schema" + rules: + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L521 + owasp:api4:2019-string-limit: off -- files: - - '**#/paths/~1api~1v0~1vote~1active~1plans/get/responses' - - '**#/paths/~1api~1v1~1votes~1plan~1account-votes~1%7Baccount_id%7D/get/responses' - rules: - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L506 - owasp:api4:2019-array-limit: off + - files: + - "**#/paths/~1api~1v0~1vote~1active~1plans/get/responses" + - "**#/paths/~1api~1v1~1votes~1plan~1account-votes~1%7Baccount_id%7D/get/responses" + rules: + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L506 + owasp:api4:2019-array-limit: off -- files: - - '**#/components/schemas/FragmentStatus' - rules: - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L678 - owasp:api6:2019-no-additionalProperties: off + - files: + - "**#/components/schemas/FragmentStatus" + rules: + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L678 + owasp:api6:2019-no-additionalProperties: off -- files: - - '**#/paths/~1api~1v1~1fragments~1statuses/get/responses' - rules: - # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L698 - owasp:api6:2019-constrained-additionalProperties: off + - files: + - "**#/paths/~1api~1v1~1fragments~1statuses/get/responses" + rules: + # Ref: https://github.com/stoplightio/spectral-owasp-ruleset/blob/2fd49c377794222352ff10dee99ed2a106c35199/src/ruleset.ts#L698 + owasp:api6:2019-constrained-additionalProperties: off diff --git a/catalyst-gateway/tests/Earthfile b/catalyst-gateway/tests/Earthfile index 65a9dd0610f..c68f3ac462b 100644 --- a/catalyst-gateway/tests/Earthfile +++ b/catalyst-gateway/tests/Earthfile @@ -27,7 +27,7 @@ test-fuzzer-api: WITH DOCKER \ --compose schemathesis-docker-compose.yml \ --load schemathesis:latest=(+package-schemathesis --openapi_spec="http://127.0.0.1:3030/docs/cat-gateway.json") \ - --load event-db:latest=(../event-db+build --with_historic_data=false) \ + --load event-db:latest=(../event-db+build) \ --load cat-gateway:latest=(../+package-cat-gateway --address="127.0.0.1:3030" \ --db_url="postgres://catalyst-event-dev:CHANGE_ME@localhost/CatalystEventDev") \ --service event-db \ diff --git a/catalyst-gateway/tests/schema-mismatch/Earthfile b/catalyst-gateway/tests/schema-mismatch/Earthfile index 3c70ff7f652..d2fdfb956b7 100644 --- a/catalyst-gateway/tests/schema-mismatch/Earthfile +++ b/catalyst-gateway/tests/schema-mismatch/Earthfile @@ -25,7 +25,7 @@ test: WITH DOCKER \ --compose docker-compose.yml \ - --load event-db:latest=(../../event-db+build --with_historic_data=false) \ + --load event-db:latest=(../../event-db+build) \ --load cat-gateway:latest=(../../+package-cat-gateway --address=$CAT_ADDRESS --db_url=$DB_URL) \ --load test:latest=(+package-tester) \ --service event-db \