diff --git a/catalyst-gateway/bin/src/event_db/error.rs b/catalyst-gateway/bin/src/event_db/error.rs index d2b809e424e..a23ac12230d 100644 --- a/catalyst-gateway/bin/src/event_db/error.rs +++ b/catalyst-gateway/bin/src/event_db/error.rs @@ -35,9 +35,6 @@ pub(crate) enum Error { /// JSON Parsing error #[error("Unable to parse database data: {0}")] JsonParseIssue(String), - #[error("Decode Error: {0}")] - /// Unable to decode hex - DecodeHex(String), /// Unable to extract policy assets #[error("Unable parse assets: {0}")] AssetParsingIssue(String), diff --git a/catalyst-gateway/bin/src/event_db/follower/mod.rs b/catalyst-gateway/bin/src/event_db/follower/mod.rs index 962538c383c..8c33fa4e489 100644 --- a/catalyst-gateway/bin/src/event_db/follower/mod.rs +++ b/catalyst-gateway/bin/src/event_db/follower/mod.rs @@ -12,7 +12,7 @@ pub type SlotNumber = i64; /// Epoch pub type EpochNumber = i64; /// Block hash -pub type BlockHash = String; +pub type BlockHash = Vec; /// Unique follower id pub type MachineId = String; @@ -100,7 +100,7 @@ impl EventDB { &network.to_string(), &epoch_no, &block_time, - &hex::decode(block_hash).map_err(|e| Error::DecodeHex(e.to_string()))?, + &block_hash, ]) .await?; @@ -125,7 +125,7 @@ impl EventDB { }; let slot_number: SlotNumber = row.try_get(SLOT_NO_COLUMN)?; - let block_hash = hex::encode(row.try_get::<_, Vec>(BLOCK_HASH_COLUMN)?); + let block_hash = row.try_get(BLOCK_HASH_COLUMN)?; let block_time = row.try_get(BLOCK_TIME_COLUMN)?; Ok((slot_number, block_hash, block_time)) } @@ -146,7 +146,7 @@ impl EventDB { }; let slot_no = row.try_get(SLOT_NO_COLUMN)?; - let block_hash = hex::encode(row.try_get::<_, Vec>(BLOCK_HASH_COLUMN)?); + let block_hash = row.try_get(BLOCK_HASH_COLUMN)?; let last_updated = row.try_get(ENDED_COLUMN)?; Ok((slot_no, block_hash, last_updated)) @@ -176,7 +176,7 @@ impl EventDB { &machine_id, &slot_no, &network.to_string(), - &hex::decode(block_hash).map_err(|e| Error::DecodeHex(e.to_string()))?, + &block_hash, &update, ]) .await?; diff --git a/catalyst-gateway/bin/src/event_db/utxo.rs b/catalyst-gateway/bin/src/event_db/utxo.rs index 9554b1edf0c..5826a7da04b 100644 --- a/catalyst-gateway/bin/src/event_db/utxo.rs +++ b/catalyst-gateway/bin/src/event_db/utxo.rs @@ -14,63 +14,57 @@ pub(crate) type StakeAmount = i64; impl EventDB { /// Index utxo data - pub(crate) async fn index_utxo_data( - &self, txs: Vec>, slot_no: SlotNumber, network: Network, - ) -> Result<(), Error> { + pub(crate) async fn index_utxo_data(&self, tx: &MultiEraTx<'_>) -> Result<(), Error> { let conn = self.pool.get().await?; - for tx in txs { - let tx_hash = tx.hash(); - self.index_txn_data(tx_hash.as_slice(), slot_no, network) + let tx_hash = tx.hash(); + + // index outputs + for (index, tx_out) in tx.outputs().iter().enumerate() { + // extract assets + let assets = serde_json::to_value(parse_policy_assets(&tx_out.non_ada_assets())) + .map_err(|e| Error::AssetParsingIssue(format!("Asset parsing issue {e}")))?; + + let stake_address = match tx_out + .address() + .map_err(|e| Error::Unknown(format!("Address issue {e}")))? + { + Address::Shelley(address) => address.try_into().ok(), + Address::Stake(stake_address) => Some(stake_address), + Address::Byron(_) => None, + }; + let stake_credential = stake_address.map(|val| val.payload().as_hash().to_vec()); + + let _rows = conn + .query( + include_str!("../../../event-db/queries/utxo/insert_utxo.sql"), + &[ + &i32::try_from(index).map_err(|e| Error::Unknown(e.to_string()))?, + &tx_hash.as_slice(), + &i64::try_from(tx_out.lovelace_amount()) + .map_err(|e| Error::Unknown(e.to_string()))?, + &stake_credential, + &assets, + ], + ) + .await?; + } + // update outputs with inputs + for tx_in in tx.inputs() { + let output = tx_in.output_ref(); + let output_tx_hash = output.hash(); + let out_index = output.index(); + + let _rows = conn + .query( + include_str!("../../../event-db/queries/utxo/update_utxo.sql"), + &[ + &tx_hash.as_slice(), + &output_tx_hash.as_slice(), + &i32::try_from(out_index).map_err(|e| Error::Unknown(e.to_string()))?, + ], + ) .await?; - - // index outputs - for (index, tx_out) in tx.outputs().iter().enumerate() { - // extract assets - let assets = serde_json::to_value(parse_policy_assets(&tx_out.non_ada_assets())) - .map_err(|e| Error::AssetParsingIssue(format!("Asset parsing issue {e}")))?; - - let stake_address = match tx_out - .address() - .map_err(|e| Error::Unknown(format!("Address issue {e}")))? - { - Address::Shelley(address) => address.try_into().ok(), - Address::Stake(stake_address) => Some(stake_address), - Address::Byron(_) => None, - }; - let stake_credential = stake_address.map(|val| val.payload().as_hash().to_vec()); - - let _rows = conn - .query( - include_str!("../../../event-db/queries/utxo/insert_utxo.sql"), - &[ - &i32::try_from(index).map_err(|e| Error::Unknown(e.to_string()))?, - &tx_hash.as_slice(), - &i64::try_from(tx_out.lovelace_amount()) - .map_err(|e| Error::Unknown(e.to_string()))?, - &stake_credential, - &assets, - ], - ) - .await?; - } - // update outputs with inputs - for tx_in in tx.inputs() { - let output = tx_in.output_ref(); - let output_tx_hash = output.hash(); - let out_index = output.index(); - - let _rows = conn - .query( - include_str!("../../../event-db/queries/utxo/update_utxo.sql"), - &[ - &tx_hash.as_slice(), - &output_tx_hash.as_slice(), - &i32::try_from(out_index).map_err(|e| Error::Unknown(e.to_string()))?, - ], - ) - .await?; - } } Ok(()) @@ -94,7 +88,7 @@ impl EventDB { /// Get total utxo amount pub(crate) async fn total_utxo_amount( - &self, stake_credential: StakeCredential<'_>, network: Network, slot_num: SlotNumber, + &self, stake_credential: StakeCredential, network: Network, slot_num: SlotNumber, ) -> Result<(StakeAmount, SlotNumber), Error> { let conn = self.pool.get().await?; diff --git a/catalyst-gateway/bin/src/event_db/voter_registration.rs b/catalyst-gateway/bin/src/event_db/voter_registration.rs deleted file mode 100644 index cbbb225379c..00000000000 --- a/catalyst-gateway/bin/src/event_db/voter_registration.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! Voter registration queries - -use cardano_chain_follower::Network; -use pallas::ledger::traverse::MultiEraTx; -use serde_json::{json, Value}; - -use super::{follower::SlotNumber, Error, EventDB}; -use crate::registration::{ - parse_registrations_from_metadata, validate_reg_cddl, CddlConfig, Nonce as NonceReg, -}; - -/// Transaction id -pub(crate) type TxId = String; -/// Stake credential -pub(crate) type StakeCredential<'a> = &'a [u8]; -/// Public voting key -pub(crate) type PublicVotingKey<'a> = &'a [u8]; -/// Payment address -pub(crate) type PaymentAddress<'a> = &'a [u8]; -/// Nonce -pub(crate) type Nonce = i64; -/// Metadata 61284 -pub(crate) type MetadataCip36<'a> = &'a [u8]; -/// Stats -pub(crate) type _Stats = Option; - -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<'_>, - metadata_cip36: MetadataCip36<'_>, nonce: Nonce, report: Value, valid: bool, - ) -> Result<(), Error> { - let conn = self.pool.get().await?; - - let _rows = conn - .query( - include_str!( - "../../../event-db/queries/voter_registration/insert_voter_registration.sql" - ), - &[ - &hex::decode(tx_id).map_err(|e| Error::DecodeHex(e.to_string()))?, - &stake_credential, - &public_voting_key, - &payment_address, - &nonce, - &metadata_cip36, - &report, - &valid, - ], - ) - .await?; - - Ok(()) - } - - /// Index registration data - #[allow(dead_code)] - pub async fn index_registration_data( - &self, txs: Vec>, slot_no: SlotNumber, network: Network, - ) -> Result<(), Error> { - let cddl = CddlConfig::new(); - - for tx in txs { - let mut valid_registration = true; - - if !tx.metadata().is_empty() { - let (registration, errors_report) = - match parse_registrations_from_metadata(&tx.metadata(), network) { - Ok(registration) => registration, - Err(_err) => { - // fatal error parsing registration tx, unable to extract meaningful - // errors assume corrupted tx - continue; - }, - }; - - // cddl verification - if let Some(cip36) = registration.clone().raw_cbor_cip36 { - match validate_reg_cddl(&cip36, &cddl) { - Ok(()) => (), - Err(_err) => { - // did not pass cddl verification, not a valid registration - continue; - }, - }; - } else { - // registration does not contain cip36 61284 or 61285 keys - // not a valid registration tx - continue; - } - - self.index_txn_data(tx.hash().as_slice(), slot_no, network) - .await?; - - let report = json!(&errors_report); - - if errors_report.is_empty() { - // valid registration - self.insert_voter_registration( - tx.hash().to_string(), - ®istration.stake_key.unwrap_or_default().0 .0, - serde_json::to_string(®istration.voting_key.unwrap_or_default()) - .unwrap_or_default() - .as_bytes(), - ®istration.rewards_address.unwrap_or_default().0, - ®istration.raw_cbor_cip36.unwrap_or_default(), - registration - .nonce - .unwrap_or(NonceReg(1)) - .0 - .try_into() - .unwrap_or(0), - report, - valid_registration, - ) - .await?; - } else { - // invalid registration - // index with invalid registration flag and error report - valid_registration = false; - - self.insert_voter_registration( - tx.hash().to_string(), - ®istration.stake_key.unwrap_or_default().0 .0, - serde_json::to_string(®istration.voting_key.unwrap_or_default()) - .unwrap_or_default() - .as_bytes(), - ®istration.rewards_address.unwrap_or_default().0, - ®istration.raw_cbor_cip36.unwrap_or_default(), - registration - .nonce - .unwrap_or(NonceReg(1)) - .0 - .try_into() - .unwrap_or(0), - report, - valid_registration, - ) - .await?; - } - } - } - - Ok(()) - } -} diff --git a/catalyst-gateway/event-db/queries/voter_registration/insert_voter_registration.sql b/catalyst-gateway/bin/src/event_db/voter_registration/insert_voter_registration.sql similarity index 100% rename from catalyst-gateway/event-db/queries/voter_registration/insert_voter_registration.sql rename to catalyst-gateway/bin/src/event_db/voter_registration/insert_voter_registration.sql diff --git a/catalyst-gateway/bin/src/event_db/voter_registration/mod.rs b/catalyst-gateway/bin/src/event_db/voter_registration/mod.rs new file mode 100644 index 00000000000..f2f5fbd2671 --- /dev/null +++ b/catalyst-gateway/bin/src/event_db/voter_registration/mod.rs @@ -0,0 +1,180 @@ +//! Voter registration queries + +use cardano_chain_follower::Network; +use pallas::ledger::traverse::MultiEraTx; +use serde_json::json; + +use super::{follower::SlotNumber, Error, EventDB}; +use crate::registration::{ + parse_registrations_from_metadata, validate_reg_cddl, CddlConfig, ErrorReport, VotingInfo, +}; + +/// Transaction id +pub(crate) type TxId = Vec; +/// Stake credential +pub(crate) type StakeCredential = Vec; +/// Public voting key +pub(crate) type PublicVotingInfo = VotingInfo; +/// Payment address +pub(crate) type PaymentAddress = Vec; +/// Nonce +pub(crate) type Nonce = i64; +/// Metadata 61284 +pub(crate) type MetadataCip36 = Vec; +/// Stats +pub(crate) type _Stats = Option; + +/// `tx_id` column name +const TX_ID_COLUMN: &str = "tx_id"; +/// `payment_address` column name +const PAYMENT_ADDRESS_COLUMN: &str = "payment_address"; +/// `public_voting_key` column name +const PUBLIC_VOTING_KEY_COLUMN: &str = "public_voting_key"; +/// `nonce` column name +const NONCE_COLUMN: &str = "nonce"; + +/// `insert_voter_registration.sql` +const INSERT_VOTER_REGISTRATION_SQL: &str = include_str!("insert_voter_registration.sql"); +/// `select_voter_registration.sql` +const SELECT_VOTER_REGISTRATION_SQL: &str = include_str!("select_voter_registration.sql"); + +impl EventDB { + /// Inserts voter registration data, replacing any existing data. + #[allow(clippy::too_many_arguments)] + async fn insert_voter_registration( + &self, tx_id: TxId, stake_credential: Option, + public_voting_key: Option, payment_address: Option, + metadata_cip36: Option, nonce: Option, errors_report: ErrorReport, + ) -> Result<(), Error> { + let conn = self.pool.get().await?; + + // for the catalyst we dont support multiple delegations + let multiple_delegations = public_voting_key.as_ref().is_some_and(|voting_info| { + if let PublicVotingInfo::Delegated(delegations) = voting_info { + delegations.len() > 1 + } else { + false + } + }); + + let encoded_voting_key = if let Some(voting_key) = public_voting_key { + Some( + serde_json::to_string(&voting_key) + .map_err(|_| Error::Unknown("Cannot encode voting key".to_string()))? + .as_bytes() + .to_vec(), + ) + } else { + None + }; + + let is_valid = !multiple_delegations + && stake_credential.is_some() + && encoded_voting_key.is_some() + && payment_address.is_some() + && metadata_cip36.is_some() + && nonce.is_some() + && errors_report.is_empty(); + + let _rows = conn + .query(INSERT_VOTER_REGISTRATION_SQL, &[ + &tx_id, + &stake_credential, + &encoded_voting_key, + &payment_address, + &nonce, + &metadata_cip36, + &json!(&errors_report), + &is_valid, + ]) + .await?; + + Ok(()) + } + + /// Index registration data + pub(crate) async fn index_registration_data( + &self, tx: &MultiEraTx<'_>, network: Network, + ) -> Result<(), Error> { + let cddl = CddlConfig::new(); + + if !tx.metadata().is_empty() { + let (registration, errors_report) = + match parse_registrations_from_metadata(&tx.metadata(), network) { + Ok(registration) => registration, + Err(_err) => { + // fatal error parsing registration tx, unable to extract meaningful + // errors assume corrupted tx + return Ok(()); + }, + }; + + // cddl verification + if let Some(cip36) = registration.clone().raw_cbor_cip36 { + match validate_reg_cddl(&cip36, &cddl) { + Ok(()) => (), + Err(_err) => { + // did not pass cddl verification, not a valid registration + return Ok(()); + }, + }; + } else { + // registration does not contain cip36 61284 or 61285 keys + // not a valid registration tx + return Ok(()); + } + + let nonce = if let Some(nonce) = registration.nonce { + Some(nonce.0.try_into().map_err(|_| { + Error::Unknown("Cannot cast registration nonce from u64 to i64".to_string()) + })?) + } else { + None + }; + self.insert_voter_registration( + tx.hash().to_vec(), + registration + .stake_key + .map(|val| val.get_credentials().to_vec()), + registration.voting_key, + registration.rewards_address.map(|val| val.0), + registration.raw_cbor_cip36, + nonce, + errors_report, + ) + .await?; + } + + Ok(()) + } + + /// Get registration info + pub(crate) async fn get_registration_info( + &self, stake_credential: StakeCredential, network: Network, slot_num: SlotNumber, + ) -> Result<(TxId, PaymentAddress, PublicVotingInfo, Nonce), Error> { + let conn = self.pool.get().await?; + + let rows = conn + .query(SELECT_VOTER_REGISTRATION_SQL, &[ + &stake_credential, + &network.to_string(), + &slot_num, + ]) + .await?; + + let Some(row) = rows.first() else { + return Err(Error::NotFound); + }; + + let tx_id = row.try_get(TX_ID_COLUMN)?; + let payment_address = row.try_get(PAYMENT_ADDRESS_COLUMN)?; + let nonce = row.try_get(NONCE_COLUMN)?; + let public_voting_info = serde_json::from_str( + &String::from_utf8(row.try_get(PUBLIC_VOTING_KEY_COLUMN)?) + .map_err(|_| Error::Unknown("Cannot parse public voting key".to_string()))?, + ) + .map_err(|_| Error::Unknown("Cannot parse public voting key".to_string()))?; + + Ok((tx_id, payment_address, public_voting_info, nonce)) + } +} diff --git a/catalyst-gateway/bin/src/event_db/voter_registration/select_voter_registration.sql b/catalyst-gateway/bin/src/event_db/voter_registration/select_voter_registration.sql new file mode 100644 index 00000000000..da4c41ee6c9 --- /dev/null +++ b/catalyst-gateway/bin/src/event_db/voter_registration/select_voter_registration.sql @@ -0,0 +1,26 @@ +SELECT + cardano_voter_registration.tx_id, + cardano_voter_registration.payment_address, + cardano_voter_registration.public_voting_key, + cardano_voter_registration.nonce + +FROM cardano_voter_registration + +INNER JOIN cardano_txn_index + ON cardano_voter_registration.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 + +WHERE + cardano_voter_registration.valid = TRUE + AND cardano_voter_registration.stake_credential = $1 + AND cardano_txn_index.network = $2 + AND cardano_txn_index.slot_no <= $3 + +ORDER BY cardano_voter_registration.nonce DESC + +LIMIT 1; diff --git a/catalyst-gateway/bin/src/follower.rs b/catalyst-gateway/bin/src/follower.rs index a45b2237f00..f289ef513fb 100644 --- a/catalyst-gateway/bin/src/follower.rs +++ b/catalyst-gateway/bin/src/follower.rs @@ -147,19 +147,20 @@ async fn spawn_followers( async fn find_last_update_point( db: Arc, network: Network, ) -> anyhow::Result<(Option, Option, Option)> { - let (slot_no, block_hash, last_updated) = match db.last_updated_metadata(network).await { - Ok((slot_no, block_hash, last_updated)) => { - info!( + let (slot_no, block_hash, last_updated) = + match db.last_updated_metadata(network).await { + Ok((slot_no, block_hash, last_updated)) => { + info!( "Previous follower stopped updating at Slot_no: {} block_hash:{} last_updated: {}", - slot_no, block_hash, last_updated + slot_no, hex::encode(&block_hash), last_updated ); - (Some(slot_no), Some(block_hash), Some(last_updated)) - }, - Err(err) => { - info!("No previous followers, start from genesis. Db msg: {}", err); - (None, None, None) - }, - }; + (Some(slot_no), Some(block_hash), Some(last_updated)) + }, + Err(err) => { + info!("No previous followers, start from genesis. Db msg: {}", err); + (None, None, None) + }, + }; Ok((slot_no, block_hash, last_updated)) } @@ -224,13 +225,7 @@ async fn init_follower( }; match db - .index_follower_data( - slot, - network, - epoch, - wallclock, - hex::encode(block.hash()), - ) + .index_follower_data(slot, network, epoch, wallclock, block.hash().to_vec()) .await { Ok(()) => (), @@ -240,31 +235,41 @@ async fn init_follower( }, } - // index utxo - match db.index_utxo_data(block.txs(), slot, network).await { - Ok(()) => (), - Err(err) => { - error!("Unable to index utxo data for block {:?} - skip..", err); - continue; - }, - } + for tx in block.txs() { + // index tx + match db.index_txn_data(tx.hash().as_slice(), slot, network).await { + Ok(()) => (), + Err(err) => { + error!("Unable to index txn data {:?} - skip..", err); + continue; + }, + } - // Block processing for Eras before staking are ignored. - if valid_era(block.era()) { - // index catalyst registrations - match db.index_registration_data(block.txs(), slot, network).await { + // index utxo + match db.index_utxo_data(&tx).await { Ok(()) => (), Err(err) => { - error!( - "Unable to index registration data for block {:?} - - skip..", - err - ); + error!("Unable to index utxo data for tx {:?} - skip..", err); continue; }, } - // Rewards + // Block processing for Eras before staking are ignored. + if valid_era(block.era()) { + // index catalyst registrations + match db.index_registration_data(&tx, network).await { + Ok(()) => (), + Err(err) => { + error!( + "Unable to index registration data for tx {:?} - skip..", + err + ); + continue; + }, + } + + // Rewards + } } // Refresh update metadata for future followers @@ -272,7 +277,7 @@ async fn init_follower( .refresh_last_updated( chrono::offset::Utc::now(), slot, - hex::encode(block.hash()), + block.hash().to_vec(), network, &machine_id, ) @@ -329,11 +334,9 @@ async fn follower_connection( .0 .ok_or(anyhow::anyhow!("Slot number not present"))? .try_into()?, - hex::decode( - start_from - .1 - .ok_or(anyhow::anyhow!("Block Hash not present"))?, - )?, + start_from + .1 + .ok_or(anyhow::anyhow!("Block Hash not present"))?, )) .mithril_snapshot_path(PathBuf::from(snapshot)) .build() diff --git a/catalyst-gateway/bin/src/registration/mod.rs b/catalyst-gateway/bin/src/registration/mod.rs index a4819244d36..0216aac7ae8 100644 --- a/catalyst-gateway/bin/src/registration/mod.rs +++ b/catalyst-gateway/bin/src/registration/mod.rs @@ -4,6 +4,7 @@ use std::{error::Error, io::Cursor}; use cardano_chain_follower::Network; use ciborium::Value; +use cryptoxide::{blake2b::Blake2b, digest::Digest}; use pallas::ledger::{primitives::Fragment, traverse::MultiEraMeta}; use serde::{Deserialize, Serialize}; @@ -35,7 +36,7 @@ const CIP36_61285: usize = 61285; /// Pub key #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] -pub struct PubKey(pub Vec); +pub struct PubKey(Vec); /// Nonce #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] @@ -43,16 +44,12 @@ pub struct Nonce(pub u64); /// Voting purpose #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] -pub struct VotingPurpose(pub u64); +pub struct VotingPurpose(u64); /// Rewards address #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct RewardsAddress(pub Vec); -/// Stake key -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] -pub struct StakeKey(pub PubKey); - /// Error report for serializing pub type ErrorReport = Vec; @@ -66,6 +63,22 @@ pub type ComponentBytes = [u8; COMPONENT_SIZE]; /// Ed25519 signature serialized as a byte array. pub type SignatureBytes = [u8; Signature::BYTE_SIZE]; +impl PubKey { + /// Get credentials, a blake2b 28 bytes hash of the pub key + pub(crate) fn get_credentials(&self) -> [u8; 28] { + let mut digest = [0u8; 28]; + let mut context = Blake2b::new(28); + context.input(&self.0); + context.result(&mut digest); + digest + } + + /// Get bytes + pub(crate) fn bytes(&self) -> &[u8] { + &self.0 + } +} + /// Ed25519 signature. /// /// This type represents a container for the byte serialization of an Ed25519 @@ -127,9 +140,9 @@ impl Default for CddlConfig { #[derive(Debug, Clone, PartialEq)] pub struct Registration { /// Voting key - pub voting_key: Option, + pub voting_key: Option, /// Stake key - pub stake_key: Option, + pub stake_key: Option, /// Rewards address pub rewards_address: Option, /// Nonce @@ -150,7 +163,7 @@ pub struct Registration { #[derive(Serialize, Deserialize)] #[serde(untagged)] #[derive(Debug, Clone, PartialEq)] -pub enum VotingKey { +pub enum VotingInfo { /// Direct voting /// /// Voting power is based on the staked ada of the given key @@ -160,12 +173,12 @@ pub enum VotingKey { /// /// Voting power is based on the staked ada of the delegated keys /// order of elements is important and must be preserved. - Delegated(Vec<(PubKey, u64)>), + Delegated(Vec<(PubKey, i64)>), } -impl Default for VotingKey { +impl Default for VotingInfo { fn default() -> Self { - VotingKey::Direct(PubKey(Vec::new())) + VotingInfo::Direct(PubKey(Vec::new())) } } @@ -256,14 +269,14 @@ pub fn inspect_metamap_reg(spec_61284: &[Value]) -> Result<&Vec<(Value, Value)>, #[allow(clippy::manual_let_else)] /// Extract voting key -pub fn inspect_voting_key(metamap: &[(Value, Value)]) -> Result> { +pub fn inspect_voting_key(metamap: &[(Value, Value)]) -> Result> { let voting_key = match &metamap .get(DELEGATIONS_OR_DIRECT) .ok_or("Issue with voting key 61284 cbor parsing")? { - (Value::Integer(_one), Value::Bytes(direct)) => VotingKey::Direct(PubKey(direct.clone())), + (Value::Integer(_one), Value::Bytes(direct)) => VotingInfo::Direct(PubKey(direct.clone())), (Value::Integer(_one), Value::Array(delegations)) => { - let mut delegations_map: Vec<(PubKey, u64)> = Vec::new(); + let mut delegations_map: Vec<(PubKey, i64)> = Vec::new(); for d in delegations { match d { Value::Array(delegations) => { @@ -301,7 +314,7 @@ pub fn inspect_voting_key(metamap: &[(Value, Value)]) -> Result return Err("Invalid signature".to_string().into()), @@ -372,8 +385,8 @@ pub fn inspect_voting_purpose( pub fn parse_registrations_from_metadata( meta: &MultiEraMeta, network: Network, ) -> Result<(Registration, ErrorReport), Box> { - let mut voting_key: Option = None; - let mut stake_key: Option = None; + let mut voting_key: Option = None; + let mut stake_key: Option = None; let mut voting_purpose: Option = None; let mut rewards_address: Option = None; let mut nonce: Option = None; @@ -412,42 +425,42 @@ pub fn parse_registrations_from_metadata( // side chain that will receive voting power from this delegation. // For direct voting it's necessary to have the corresponding private key to cast // votes in the side chain - match inspect_voting_key(metamap) { - Ok(value) => voting_key = Some(value), - Err(err) => { - voting_key = None; + voting_key = inspect_voting_key(metamap).map_or_else( + |err| { errors_report.push(format!("Invalid voting key {raw_cbor:?} {err:?}")); + None }, - }; + Some, + ); // A stake address for the network that this transaction is submitted to (to point // to the Ada that is being delegated); - match inspect_stake_key(metamap) { - Ok(value) => stake_key = Some(StakeKey(value)), - Err(err) => { - stake_key = None; + stake_key = inspect_stake_key(metamap).map_or_else( + |err| { errors_report.push(format!("Invalid stake key {raw_cbor:?} {err:?}")); + None }, - }; + Some, + ); // A Shelley payment address (see CIP-0019) discriminated for the same network // this transaction is submitted to, to receive rewards. - match inspect_rewards_addr(metamap, network) { - Ok(value) => rewards_address = Some(RewardsAddress(value.clone())), - Err(err) => { - rewards_address = None; + rewards_address = inspect_rewards_addr(metamap, network).map_or_else( + |err| { errors_report.push(format!("Invalid rewards address {raw_cbor:?} {err:?}")); + None }, - }; + |val| Some(RewardsAddress(val.clone())), + ); // A nonce that identifies that most recent delegation - match inspect_nonce(metamap) { - Ok(value) => nonce = Some(value), - Err(err) => { + nonce = inspect_nonce(metamap).map_or_else( + |err| { errors_report.push(format!("Invalid nonce {raw_cbor:?} {err:?}")); - nonce = None; + None }, - }; + Some, + ); // A non-negative integer that indicates the purpose of the vote. // This is an optional field to allow for compatibility with CIP-15 diff --git a/catalyst-gateway/bin/src/service/api/cardano/date_time_to_slot_number_get.rs b/catalyst-gateway/bin/src/service/api/cardano/date_time_to_slot_number_get.rs index df4626b7766..2927130f7af 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/date_time_to_slot_number_get.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/date_time_to_slot_number_get.rs @@ -75,7 +75,7 @@ pub(crate) async fn endpoint( Ok((slot_number, block_hash, block_time)) => { Ok(Some(Slot { slot_number, - block_hash, + block_hash: From::from(block_hash), block_time, })) }, diff --git a/catalyst-gateway/bin/src/service/api/cardano/mod.rs b/catalyst-gateway/bin/src/service/api/cardano/mod.rs index 64b61aa2708..1aad90fce09 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/mod.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/mod.rs @@ -21,6 +21,7 @@ use crate::{ }; mod date_time_to_slot_number_get; +mod registration_get; mod staked_ada_get; mod sync_state_get; @@ -69,6 +70,46 @@ impl CardanoApi { staked_ada_get::endpoint(&data, stake_address.0, network.0, slot_number.0).await } + #[oai( + path = "/registration/:stake_address", + method = "get", + operation_id = "registrationGet", + transform = "schema_version_validation" + )] + /// Get registration info. + /// + /// This endpoint returns the registration info followed by the [CIP-36](https://cips.cardano.org/cip/CIP-36/) to the + /// corresponded user's stake address. + /// + /// ## Responses + /// * 200 OK - Returns the registration info. + /// * 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 registration_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>, + /// Slot number at which the staked ada amount should be calculated. + /// If omitted latest slot number is used. + // TODO(bkioshn): https://github.com/input-output-hk/catalyst-voices/issues/239 + #[oai(validator(minimum(value = "0"), maximum(value = "9223372036854775807")))] + slot_number: Query>, + ) -> registration_get::AllResponses { + registration_get::endpoint(&data, stake_address.0, network.0, slot_number.0).await + } + #[oai( path = "/sync_state", method = "get", diff --git a/catalyst-gateway/bin/src/service/api/cardano/registration_get.rs b/catalyst-gateway/bin/src/service/api/cardano/registration_get.rs new file mode 100644 index 00000000000..6ba43be1511 --- /dev/null +++ b/catalyst-gateway/bin/src/service/api/cardano/registration_get.rs @@ -0,0 +1,79 @@ +//! Implementation of the GET `/registration` endpoint + +use poem_extensions::{ + response, + UniResponse::{T200, T400, T404, T503}, +}; +use poem_openapi::payload::Json; + +use crate::{ + cli::Error, + event_db::{error::Error as DBError, follower::SlotNumber}, + service::{ + common::{ + objects::cardano::{ + network::Network, registration_info::RegistrationInfo, stake_address::StakeAddress, + }, + responses::{ + resp_2xx::OK, + resp_4xx::{ApiValidationError, NotFound}, + resp_5xx::{server_error_response, ServerError, ServiceUnavailable}, + }, + }, + utilities::check_network, + }, + state::{SchemaVersionStatus, State}, +}; + +/// # All Responses +#[allow(dead_code)] +pub(crate) type AllResponses = response! { + 200: OK>, + 400: ApiValidationError, + 404: NotFound, + 500: ServerError, + 503: ServiceUnavailable, +}; + +/// # GET `/registration` +pub(crate) async fn endpoint( + state: &State, stake_address: StakeAddress, provided_network: Option, + slot_num: Option, +) -> AllResponses { + let event_db = match state.event_db() { + Ok(event_db) => event_db, + 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); + return T503(ServiceUnavailable); + }, + Err(err) => return server_error_response!("{err}"), + }; + let date_time = slot_num.unwrap_or(SlotNumber::MAX); + let stake_credential = stake_address.payload().as_hash().to_vec(); + let network = match check_network(stake_address.network(), provided_network) { + Ok(network) => network, + Err(err) => return T400(err), + }; + + // get the total utxo amount from the database + match event_db + .get_registration_info(stake_credential, network.into(), date_time) + .await + { + Ok((tx_id, payment_address, voting_info, nonce)) => { + T200(OK(Json(RegistrationInfo::new( + tx_id, + &payment_address, + voting_info, + nonce, + )))) + }, + Err(DBError::NotFound) => T404(NotFound), + Err(err) => server_error_response!("{err}"), + } +} diff --git a/catalyst-gateway/bin/src/service/api/cardano/staked_ada_get.rs b/catalyst-gateway/bin/src/service/api/cardano/staked_ada_get.rs index 9ebbe0610f8..4c6f33cc0b2 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/staked_ada_get.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/staked_ada_get.rs @@ -4,18 +4,23 @@ use poem_extensions::{ response, UniResponse::{T200, T400, T404, T503}, }; -use poem_openapi::{payload::Json, types::ToJSON}; +use poem_openapi::payload::Json; use crate::{ cli::Error, event_db::{error::Error as DBError, follower::SlotNumber}, - service::common::{ - objects::cardano::{network::Network, stake_address::StakeAddress, stake_info::StakeInfo}, - responses::{ - resp_2xx::OK, - resp_4xx::{ApiValidationError, NotFound}, - resp_5xx::{server_error_response, ServerError, ServiceUnavailable}, + service::{ + common::{ + objects::cardano::{ + network::Network, stake_address::StakeAddress, stake_info::StakeInfo, + }, + responses::{ + resp_2xx::OK, + resp_4xx::{ApiValidationError, NotFound}, + resp_5xx::{server_error_response, ServerError, ServiceUnavailable}, + }, }, + utilities::check_network, }, state::{SchemaVersionStatus, State}, }; @@ -29,48 +34,7 @@ pub(crate) type AllResponses = response! { 503: ServiceUnavailable, }; -/// Check the provided network type with the encoded inside the stake address -fn check_network( - address_network: pallas::ledger::addresses::Network, provided_network: Option, -) -> Result { - match address_network { - pallas::ledger::addresses::Network::Mainnet => { - if let Some(network) = provided_network { - if !matches!(&network, Network::Mainnet) { - return Err(ApiValidationError::new(format!( - "Provided network type {} does not match stake address network type Mainnet", network.to_json_string() - ))); - } - } - Ok(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 Err(ApiValidationError::new(format!( - "Provided network type {} does not match stake address network type Testnet", network.to_json_string() - ))); - } - Ok(network) - } else { - Ok(Network::Testnet) - } - }, - pallas::ledger::addresses::Network::Other(x) => { - Err(ApiValidationError::new(format!("Unknown network type {x}"))) - }, - } -} - /// # GET `/staked_ada` -#[allow(clippy::unused_async)] pub(crate) async fn endpoint( state: &State, stake_address: StakeAddress, provided_network: Option, slot_num: Option, @@ -90,7 +54,7 @@ pub(crate) async fn endpoint( }; let date_time = slot_num.unwrap_or(SlotNumber::MAX); - let stake_credential = stake_address.payload().as_hash().as_ref(); + let stake_credential = stake_address.payload().as_hash().to_vec(); let network = match check_network(stake_address.network(), provided_network) { Ok(network) => network, diff --git a/catalyst-gateway/bin/src/service/api/cardano/sync_state_get.rs b/catalyst-gateway/bin/src/service/api/cardano/sync_state_get.rs index 32c8d3fe420..ab0801acdef 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/sync_state_get.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/sync_state_get.rs @@ -52,7 +52,7 @@ pub(crate) async fn endpoint(state: &State, network: Option) -> AllResp Ok((slot_number, block_hash, last_updated)) => { T200(OK(Json(SyncState { slot_number, - block_hash, + block_hash: block_hash.into(), last_updated, }))) }, diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/hash.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/hash.rs new file mode 100644 index 00000000000..b0d2dcb6977 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/hash.rs @@ -0,0 +1,96 @@ +//! Defines API schema of Cardano hash type. + +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef, Registry}, + types::{ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; + +use crate::{event_db::follower::BlockHash, service::utilities::to_hex_with_prefix}; + +/// Cardano Blake2b256 hash encoded in hex. +#[derive(Debug)] +pub(crate) struct Hash(Vec); + +impl From> for Hash { + fn from(hash: BlockHash) -> Self { + Self(hash) + } +} + +impl Hash { + /// Creates a `CardanoStakeAddress` schema definition. + fn schema() -> MetaSchema { + let mut schema = MetaSchema::new("string"); + schema.title = Some(Self::name().to_string()); + schema.description = Some("Cardano Blake2b256 hash encoded in hex."); + schema.example = Some(serde_json::Value::String( + // cspell: disable + "0x0000000000000000000000000000000000000000000000000000000000000000".to_string(), + // cspell: enable + )); + schema.max_length = Some(66); + schema.pattern = Some("0x[0-9a-f]{64}".to_string()); + schema + } +} + +impl Type for Hash { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "CardanoHash".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 Hash { + fn parse_from_parameter(param: &str) -> ParseResult { + hex::decode(param.strip_prefix("0x").ok_or(ParseError::custom( + "Invalid Cardano hash. hex string should start with `0x`.", + ))?) + .map_err(|_| ParseError::custom("Invalid Cardano hash. Should be hex string.")) + .map(Self) + } +} + +impl ParseFromJSON for Hash { + fn parse_from_json(value: Option) -> ParseResult { + let value = value.ok_or(ParseError::custom( + "Invalid Cardano hash. Null or missing value.", + ))?; + let str = value.as_str().ok_or(ParseError::custom( + "Invalid Cardano hash. Should be string.", + ))?; + hex::decode(str.strip_prefix("0x").ok_or(ParseError::custom( + "Invalid Cardano hash. hex string should start with `0x`.", + ))?) + .map_err(|_| ParseError::custom("Invalid Cardano hash. Should be hex string.")) + .map(Self) + } +} + +impl ToJSON for Hash { + fn to_json(&self) -> Option { + Some(serde_json::Value::String(to_hex_with_prefix(&self.0))) + } +} diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/mod.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/mod.rs index 4b9af9687f7..d6ca60e4dcf 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/mod.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/mod.rs @@ -1,6 +1,8 @@ //! Defines API schemas of Cardano types. +pub(crate) mod hash; pub(crate) mod network; +pub(crate) mod registration_info; pub(crate) mod slot_info; pub(crate) mod stake_address; pub(crate) mod stake_info; diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/registration_info.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/registration_info.rs new file mode 100644 index 00000000000..81d17451c28 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/registration_info.rs @@ -0,0 +1,104 @@ +//! Defines API schemas of [CIP-36](https://cips.cardano.org/cip/CIP-36/) registration type. + +use poem_openapi::{types::Example, Object, Union}; + +use crate::{ + event_db::voter_registration::{Nonce, PaymentAddress, PublicVotingInfo, TxId}, + service::{common::objects::cardano::hash::Hash, utilities::to_hex_with_prefix}, +}; + +/// Delegation type +#[derive(Object)] +struct Delegation { + /// Voting key. + #[oai(validator(min_length = "66", max_length = "66", pattern = "0x[0-9a-f]{64}"))] + voting_key: String, + + /// Delegation power assigned to the voting key. + // TODO(bkioshn): https://github.com/input-output-hk/catalyst-voices/issues/239 + #[oai(validator(minimum(value = "0"), maximum(value = "9223372036854775807")))] + power: i64, +} + +/// Voting key type +#[derive(Union)] +enum VotingInfo { + /// direct voting key + Direct(String), + /// delegations + Delegated(Vec), +} + +/// User's [CIP-36](https://cips.cardano.org/cip/CIP-36/) registration info. +#[derive(Object)] +#[oai(example = true)] +pub(crate) struct RegistrationInfo { + /// Rewards address. + #[oai(validator(min_length = "2", max_length = "116", pattern = "0x[0-9a-f]*"))] + rewards_address: String, + + /// Transaction hash in which the [CIP-36](https://cips.cardano.org/cip/CIP-36/) registration is made. + tx_hash: Hash, + + /// Registration nonce. + // TODO(bkioshn): https://github.com/input-output-hk/catalyst-voices/issues/239 + #[oai(validator(minimum(value = "0"), maximum(value = "9223372036854775807")))] + nonce: Nonce, + + /// Voting info. + voting_info: VotingInfo, +} + +impl RegistrationInfo { + /// Creates a new `RegistrationInfo` + pub(crate) fn new( + tx_hash: TxId, rewards_address: &PaymentAddress, voting_info: PublicVotingInfo, + nonce: Nonce, + ) -> Self { + let voting_info = match voting_info { + PublicVotingInfo::Direct(voting_key) => { + VotingInfo::Direct(to_hex_with_prefix(voting_key.bytes())) + }, + PublicVotingInfo::Delegated(delegations) => { + VotingInfo::Delegated( + delegations + .into_iter() + .map(|(voting_key, power)| { + Delegation { + voting_key: to_hex_with_prefix(voting_key.bytes()), + power, + } + }) + .collect(), + ) + }, + }; + Self { + tx_hash: tx_hash.into(), + rewards_address: to_hex_with_prefix(rewards_address), + nonce, + voting_info, + } + } +} + +impl Example for RegistrationInfo { + #[allow(clippy::expect_used)] + fn example() -> Self { + Self { + rewards_address: "0xe0f9722f71d23654387ec1389fe253d380653f4f7e7305a80cf5c4dfa1" + .to_string(), + tx_hash: hex::decode( + "27551498616e8da138780350a7cb8c18ef72cb01b0a6d40c785d095bcc8b1973", + ) + .expect("Invalid hex") + .into(), + nonce: 11_623_850, + voting_info: VotingInfo::Delegated(vec![Delegation { + voting_key: "0xb16f03d67e95ddd321df4bee8658901eb183d4cb5623624ff5edd7fe54f8e857" + .to_string(), + power: 1, + }]), + } + } +} diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/slot_info.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/slot_info.rs index 161be7baf8e..4b7f17e68ed 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/slot_info.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/slot_info.rs @@ -2,7 +2,10 @@ use poem_openapi::{types::Example, Object}; -use crate::event_db::follower::{BlockHash, DateTime, SlotNumber}; +use crate::{ + event_db::follower::{DateTime, SlotNumber}, + service::common::objects::cardano::hash::Hash, +}; /// Cardano block's slot data. #[derive(Object)] @@ -15,8 +18,7 @@ pub(crate) struct Slot { pub(crate) slot_number: SlotNumber, /// Block hash. - #[oai(validator(min_length = "66", max_length = "66", pattern = "0x[0-9a-f]{64}"))] - pub(crate) block_hash: BlockHash, + pub(crate) block_hash: Hash, /// Block time. pub(crate) block_time: DateTime, @@ -27,8 +29,11 @@ impl Example for Slot { fn example() -> Self { Self { slot_number: 121_099_410, - block_hash: "0xaa34657bf91e04eb5b506d76a66f688dbfbc509dbf70bc38124d4e8832fdd68a" - .to_string(), + block_hash: hex::decode( + "aa34657bf91e04eb5b506d76a66f688dbfbc509dbf70bc38124d4e8832fdd68a", + ) + .expect("Invalid hex") + .into(), block_time: chrono::DateTime::from_timestamp(1_712_676_501, 0) .expect("Invalid timestamp"), } @@ -55,22 +60,31 @@ impl Example for SlotInfo { Self { previous: Some(Slot { slot_number: 121_099_406, - block_hash: "0x162ae0e2d08dd238233308eef328bf39ba529b82bc0b87c4eeea3c1dae4fc877" - .to_string(), + block_hash: hex::decode( + "162ae0e2d08dd238233308eef328bf39ba529b82bc0b87c4eeea3c1dae4fc877", + ) + .expect("Invalid hex") + .into(), block_time: chrono::DateTime::from_timestamp(1_712_676_497, 0) .expect("Invalid timestamp"), }), current: Some(Slot { slot_number: 121_099_409, - block_hash: "0xaa34657bf91e04eb5b506d76a66f688dbfbc509dbf70bc38124d4e8832fdd68a" - .to_string(), + block_hash: hex::decode( + "aa34657bf91e04eb5b506d76a66f688dbfbc509dbf70bc38124d4e8832fdd68a", + ) + .expect("Invalid hex") + .into(), block_time: chrono::DateTime::from_timestamp(1_712_676_501, 0) .expect("Invalid timestamp"), }), next: Some(Slot { slot_number: 121_099_422, - block_hash: "0x83ad63288ae14e75de1a1f794bda5d317fa59cbdbf1cc4dc83471d76555a5e89" - .to_string(), + block_hash: hex::decode( + "83ad63288ae14e75de1a1f794bda5d317fa59cbdbf1cc4dc83471d76555a5e89", + ) + .expect("Invalid hex") + .into(), block_time: chrono::DateTime::from_timestamp(1_712_676_513, 0) .expect("Invalid timestamp"), }), diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/stake_address.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/stake_address.rs index 9d5e357aa72..52ac82d61c4 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/stake_address.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/stake_address.rs @@ -17,7 +17,7 @@ impl StakeAddress { /// Creates a `CardanoStakeAddress` schema definition. fn schema() -> MetaSchema { let mut schema = MetaSchema::new("string"); - schema.title = Some("CardanoStakeAddress".to_string()); + schema.title = Some(Self::name().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 diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/sync_state.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/sync_state.rs index e157009a67c..ea05a90fa4c 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/sync_state.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/sync_state.rs @@ -2,7 +2,10 @@ use poem_openapi::{types::Example, Object}; -use crate::event_db::follower::{BlockHash, DateTime, SlotNumber}; +use crate::{ + event_db::follower::{DateTime, SlotNumber}, + service::common::objects::cardano::hash::Hash, +}; /// Cardano follower's sync state info. #[derive(Debug, Object)] @@ -14,20 +17,22 @@ pub(crate) struct SyncState { pub(crate) slot_number: SlotNumber, /// Block hash. - #[oai(validator(min_length = "66", max_length = "66", pattern = "0x[0-9a-f]{64}"))] - pub(crate) block_hash: BlockHash, + pub(crate) block_hash: Hash, /// last updated time. pub(crate) last_updated: DateTime, } impl Example for SyncState { + #[allow(clippy::expect_used)] fn example() -> Self { Self { slot_number: 5, - block_hash: "0x0000000000000000000000000000000000000000000000000000000000000000" - .parse() - .unwrap(), + block_hash: hex::decode( + "0000000000000000000000000000000000000000000000000000000000000000", + ) + .expect("Invalid hex") + .into(), last_updated: chrono::DateTime::default(), } } diff --git a/catalyst-gateway/bin/src/service/utilities/mod.rs b/catalyst-gateway/bin/src/service/utilities/mod.rs index 4a4a45b0c75..bc06324bfd3 100644 --- a/catalyst-gateway/bin/src/service/utilities/mod.rs +++ b/catalyst-gateway/bin/src/service/utilities/mod.rs @@ -1,3 +1,55 @@ -//! `APi` Utility operations +//! `API` Utility operations pub(crate) mod catch_panic; pub(crate) mod middleware; + +use pallas::ledger::addresses::Network as PallasNetwork; +use poem_openapi::types::ToJSON; + +use crate::service::common::{ + objects::cardano::network::Network, responses::resp_4xx::ApiValidationError, +}; + +/// Convert bytes to hex string with the `0x` prefix +pub(crate) fn to_hex_with_prefix(bytes: &[u8]) -> String { + format!("0x{}", hex::encode(bytes)) +} + +/// Check the provided network type with the encoded inside the stake address +pub(crate) fn check_network( + address_network: PallasNetwork, provided_network: Option, +) -> Result { + match address_network { + PallasNetwork::Mainnet => { + if let Some(network) = provided_network { + if !matches!(&network, Network::Mainnet) { + return Err(ApiValidationError::new(format!( + "Provided network type {} does not match stake address network type Mainnet", network.to_json_string() + ))); + } + } + Ok(Network::Mainnet) + }, + PallasNetwork::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 Err(ApiValidationError::new(format!( + "Provided network type {} does not match stake address network type Testnet", network.to_json_string() + ))); + } + Ok(network) + } else { + Ok(Network::Testnet) + } + }, + PallasNetwork::Other(x) => { + Err(ApiValidationError::new(format!("Unknown network type {x}"))) + }, + } +} diff --git a/catalyst-gateway/tests/Earthfile b/catalyst-gateway/tests/Earthfile index fe0ec12ff5c..1dc99873828 100644 --- a/catalyst-gateway/tests/Earthfile +++ b/catalyst-gateway/tests/Earthfile @@ -66,7 +66,9 @@ fuzzer-api: # test-lint-openapi - OpenAPI linting from an artifact # testing whether the OpenAPI generated during build stage follows good practice. -test-lint-openapi: +# TODO: https://github.com/input-output-hk/catalyst-voices/issues/408 +# enable this linting after this issue will be solved +lint-openapi: FROM github.com/input-output-hk/catalyst-ci/earthly/spectral:v2.4.0+spectral-base # Copy the doc artifact. COPY ../+build/doc ./doc diff --git a/catalyst-gateway/tests/api_tests/.gitignore b/catalyst-gateway/tests/api_tests/.gitignore new file mode 100644 index 00000000000..ed8ebf583f7 --- /dev/null +++ b/catalyst-gateway/tests/api_tests/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/catalyst-gateway/tests/api_tests/api_tests/__init__.py b/catalyst-gateway/tests/api_tests/api_tests/__init__.py index d25f0c1b4ee..47a40143c68 100644 --- a/catalyst-gateway/tests/api_tests/api_tests/__init__.py +++ b/catalyst-gateway/tests/api_tests/api_tests/__init__.py @@ -70,6 +70,17 @@ def get_date_time_to_slot_number(network: str, date_time: datetime): return resp.json() +def get_voter_registration(address: str, network: str, slot_number: int): + resp = requests.get( + cat_gateway_endpoint_url( + f"api/cardano/registration/{address}?network={network}&slot_number={slot_number}" + ) + ) + assert resp.status_code == 200 or resp.status_code == 404 + if resp.status_code == 200: + return resp.json() + + # Wait until service will sync to the provided slot number def sync_to(network: str, slot_num: int, timeout: int): start_time = time.time() diff --git a/catalyst-gateway/tests/api_tests/api_tests/test_slot_info.py b/catalyst-gateway/tests/api_tests/api_tests/test_slot_info.py index 2e4b70eb0ea..06602b6cdf6 100644 --- a/catalyst-gateway/tests/api_tests/api_tests/test_slot_info.py +++ b/catalyst-gateway/tests/api_tests/api_tests/test_slot_info.py @@ -29,19 +29,19 @@ def test_date_time_to_slot_number_endpoint(): res["current"] != None and res["current"]["slot_number"] == 16010006 and res["current"]["block_hash"] - == "65b13e1227c36a3327fb1333ae801d15c50c7f5af66919d467befce8d67a4284" + == "0x65b13e1227c36a3327fb1333ae801d15c50c7f5af66919d467befce8d67a4284" ) assert ( res["previous"] != None and res["previous"]["slot_number"] == 16009980 and res["previous"]["block_hash"] - == "2e8475d3c4cf7fb97fa6d99ab29e05b39635b99253f1e27b9097acf5c4f4239d" + == "0x2e8475d3c4cf7fb97fa6d99ab29e05b39635b99253f1e27b9097acf5c4f4239d" ) assert ( res["next"] != None and res["next"]["slot_number"] == 16010056 and res["next"]["block_hash"] - == "9768fb8df7c3e336da30c82dd93dc664135f866080c773402b528288c970c5b0" + == "0x9768fb8df7c3e336da30c82dd93dc664135f866080c773402b528288c970c5b0" ) diff --git a/catalyst-gateway/tests/api_tests/api_tests/test_voter_registration.py b/catalyst-gateway/tests/api_tests/api_tests/test_voter_registration.py new file mode 100644 index 00000000000..5b969dea473 --- /dev/null +++ b/catalyst-gateway/tests/api_tests/api_tests/test_voter_registration.py @@ -0,0 +1,56 @@ +import json +from loguru import logger +from api_tests import ( + check_is_live, + check_is_ready, + get_voter_registration, + sync_to, + utils, +) + + +def check_delegations(provided, expected): + if type(expected) is list: + for i in range(0, len(expected)): + expected_voting_key = expected[i][0] + expected_power = expected[i][1] + provided_voting_key = provided[i]["voting_key"] + provided_power = provided[i]["power"] + assert ( + expected_voting_key == provided_voting_key + and expected_power == provided_power + ) + else: + assert provided == expected + + +def test_voter_registration_endpoint(): + check_is_live() + check_is_ready() + + network = "preprod" + slot_num = 56364174 + + # block hash `871b1e4af4c2d433618992fb1c1b5c1182ab829a236d58a4fcc82faf785b58cd` + # 60 second timeout (3 block times iof syncing from tip) + sync_to(network=network, slot_num=slot_num, timeout=60) + + snapshot_tool_data = json.load(open("./snapshot_tool-56364174.json")) + for entry in snapshot_tool_data: + expected_rewards_address = entry["rewards_address"] + expected_nonce = entry["nonce"] + stake_address = utils.stake_public_key_to_address( + key=entry["stake_public_key"][2:], is_stake=True, network_type=network + ) + res = get_voter_registration( + stake_address, network=network, slot_number=slot_num + ) + logger.info(f"checking stake address: {stake_address}") + # it is possible that snapshot tool collected data for the stake key which does not have any unspent utxo + # at this case cat-gateway return 404, that is why we are checking this case additionally + logger.info(f"curent: {res}, expected: {entry}") + assert ( + res["rewards_address"] == expected_rewards_address + and res["nonce"] == expected_nonce + ) + check_delegations(res["voting_info"], entry["delegations"]) diff --git a/catalyst-gateway/tests/api_tests/snapshot_tool-56364174.json b/catalyst-gateway/tests/api_tests/snapshot_tool-56364174.json index c9b2face88e..8f0a67caadd 100644 --- a/catalyst-gateway/tests/api_tests/snapshot_tool-56364174.json +++ b/catalyst-gateway/tests/api_tests/snapshot_tool-56364174.json @@ -205,7 +205,7 @@ "voting_power": 403470717, "voting_purpose": 0, "tx_id": 1205915, - "nonce": 36151489 + "nonce": 36168765 }, { "delegations": [ @@ -224,7 +224,7 @@ { "delegations": [ [ - "0x926cddb15c7ec46a71db0323d1d8ab18a83f8f9d73e159000b2dcfc3f7050de0", + "0x030606c166a947a33bf60043e70f240eac1df7936aa056429f2521553f9d4220", 1 ] ], @@ -233,7 +233,7 @@ "voting_power": 894284145, "voting_purpose": 0, "tx_id": 1151432, - "nonce": 36190561 + "nonce": 181865062 }, { "delegations": "0x1493fb2c46ef5e99928f08776b83166b2b18833389d06f8cb66e816e3829a627", diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.models.swagger.dart b/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.models.swagger.dart index 895c39d38f4..546441f8f24 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.models.swagger.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.models.swagger.dart @@ -156,6 +156,60 @@ extension $DelegatePublicKeyExtension on DelegatePublicKey { } } +@JsonSerializable(explicitToJson: true) +class Delegation { + const Delegation({ + required this.votingKey, + required this.power, + }); + + factory Delegation.fromJson(Map json) => + _$DelegationFromJson(json); + + static const toJsonFactory = _$DelegationToJson; + Map toJson() => _$DelegationToJson(this); + + @JsonKey(name: 'voting_key') + final String votingKey; + @JsonKey(name: 'power') + final int power; + static const fromJsonFactory = _$DelegationFromJson; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other is Delegation && + (identical(other.votingKey, votingKey) || + const DeepCollectionEquality() + .equals(other.votingKey, votingKey)) && + (identical(other.power, power) || + const DeepCollectionEquality().equals(other.power, power))); + } + + @override + String toString() => jsonEncode(this); + + @override + int get hashCode => + const DeepCollectionEquality().hash(votingKey) ^ + const DeepCollectionEquality().hash(power) ^ + runtimeType.hashCode; +} + +extension $DelegationExtension on Delegation { + Delegation copyWith({String? votingKey, int? power}) { + return Delegation( + votingKey: votingKey ?? this.votingKey, power: power ?? this.power); + } + + Delegation copyWithWrapped( + {Wrapped? votingKey, Wrapped? power}) { + return Delegation( + votingKey: (votingKey != null ? votingKey.value : this.votingKey), + power: (power != null ? power.value : this.power)); + } +} + @JsonSerializable(explicitToJson: true) class FragmentStatus { const FragmentStatus(); @@ -330,6 +384,87 @@ extension $HashExtension on Hash { } } +@JsonSerializable(explicitToJson: true) +class RegistrationInfo { + const RegistrationInfo({ + required this.rewardsAddress, + required this.txHash, + required this.nonce, + required this.votingInfo, + }); + + factory RegistrationInfo.fromJson(Map json) => + _$RegistrationInfoFromJson(json); + + static const toJsonFactory = _$RegistrationInfoToJson; + Map toJson() => _$RegistrationInfoToJson(this); + + @JsonKey(name: 'rewards_address') + final String rewardsAddress; + @JsonKey(name: 'tx_hash') + final String txHash; + @JsonKey(name: 'nonce') + final int nonce; + @JsonKey(name: 'voting_info') + final VotingInfo votingInfo; + static const fromJsonFactory = _$RegistrationInfoFromJson; + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other is RegistrationInfo && + (identical(other.rewardsAddress, rewardsAddress) || + const DeepCollectionEquality() + .equals(other.rewardsAddress, rewardsAddress)) && + (identical(other.txHash, txHash) || + const DeepCollectionEquality().equals(other.txHash, txHash)) && + (identical(other.nonce, nonce) || + const DeepCollectionEquality().equals(other.nonce, nonce)) && + (identical(other.votingInfo, votingInfo) || + const DeepCollectionEquality() + .equals(other.votingInfo, votingInfo))); + } + + @override + String toString() => jsonEncode(this); + + @override + int get hashCode => + const DeepCollectionEquality().hash(rewardsAddress) ^ + const DeepCollectionEquality().hash(txHash) ^ + const DeepCollectionEquality().hash(nonce) ^ + const DeepCollectionEquality().hash(votingInfo) ^ + runtimeType.hashCode; +} + +extension $RegistrationInfoExtension on RegistrationInfo { + RegistrationInfo copyWith( + {String? rewardsAddress, + String? txHash, + int? nonce, + VotingInfo? votingInfo}) { + return RegistrationInfo( + rewardsAddress: rewardsAddress ?? this.rewardsAddress, + txHash: txHash ?? this.txHash, + nonce: nonce ?? this.nonce, + votingInfo: votingInfo ?? this.votingInfo); + } + + RegistrationInfo copyWithWrapped( + {Wrapped? rewardsAddress, + Wrapped? txHash, + Wrapped? nonce, + Wrapped? votingInfo}) { + return RegistrationInfo( + rewardsAddress: (rewardsAddress != null + ? rewardsAddress.value + : this.rewardsAddress), + txHash: (txHash != null ? txHash.value : this.txHash), + nonce: (nonce != null ? nonce.value : this.nonce), + votingInfo: (votingInfo != null ? votingInfo.value : this.votingInfo)); + } +} + @JsonSerializable(explicitToJson: true) class RejectedFragment { const RejectedFragment({ @@ -1072,6 +1207,7 @@ extension $VoterRegistrationExtension on VoterRegistration { } } +typedef VotingInfo = Map; String? animalsNullableToJson(enums.Animals? animals) { return animals?.value; } diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.models.swagger.g.dart b/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.models.swagger.g.dart index e2b5ca8f22f..b46525fd534 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.models.swagger.g.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.models.swagger.g.dart @@ -38,6 +38,17 @@ Map _$DelegatePublicKeyToJson(DelegatePublicKey instance) => 'address': instance.address, }; +Delegation _$DelegationFromJson(Map json) => Delegation( + votingKey: json['voting_key'] as String, + power: json['power'] as int, + ); + +Map _$DelegationToJson(Delegation instance) => + { + 'voting_key': instance.votingKey, + 'power': instance.power, + }; + FragmentStatus _$FragmentStatusFromJson(Map json) => FragmentStatus(); @@ -87,6 +98,22 @@ Map _$HashToJson(Hash instance) => { 'hash': instance.hash, }; +RegistrationInfo _$RegistrationInfoFromJson(Map json) => + RegistrationInfo( + rewardsAddress: json['rewards_address'] as String, + txHash: json['tx_hash'] as String, + nonce: json['nonce'] as int, + votingInfo: json['voting_info'] as Map, + ); + +Map _$RegistrationInfoToJson(RegistrationInfo instance) => + { + 'rewards_address': instance.rewardsAddress, + 'tx_hash': instance.txHash, + 'nonce': instance.nonce, + 'voting_info': instance.votingInfo, + }; + RejectedFragment _$RejectedFragmentFromJson(Map json) => RejectedFragment( id: json['id'] as String, diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.swagger.chopper.dart b/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.swagger.chopper.dart index 271920dcff5..7af0561ff4f 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.swagger.chopper.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.swagger.chopper.dart @@ -101,6 +101,26 @@ final class _$CatGatewayApi extends CatGatewayApi { return client.send($request); } + @override + Future> _apiCardanoRegistrationStakeAddressGet({ + required String? stakeAddress, + String? network, + int? slotNumber, + }) { + final Uri $url = Uri.parse('/api/cardano/registration/${stakeAddress}'); + final Map $params = { + 'network': network, + 'slot_number': slotNumber, + }; + final Request $request = Request( + 'GET', + $url, + client.baseUrl, + parameters: $params, + ); + return client.send($request); + } + @override Future> _apiCardanoSyncStateGet({String? network}) { final Uri $url = Uri.parse('/api/cardano/sync_state'); diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.swagger.dart b/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.swagger.dart index 3ef5fec757e..a20a2d48fed 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.swagger.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/generated/catalyst_gateway/cat_gateway_api.swagger.dart @@ -153,6 +153,37 @@ abstract class CatGatewayApi extends ChopperService { @Query('slot_number') int? slotNumber, }); + ///Get registration info. + ///@param stake_address The stake address of the user. Should a valid Bech32 encoded address followed by the https://cips.cardano.org/cip/CIP-19/#stake-addresses. + ///@param network 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. + ///@param slot_number Slot number at which the staked ada amount should be calculated. If omitted latest slot number is used. + Future> + apiCardanoRegistrationStakeAddressGet({ + required String? stakeAddress, + enums.Network? network, + int? slotNumber, + }) { + generatedMapping.putIfAbsent( + RegistrationInfo, () => RegistrationInfo.fromJsonFactory); + + return _apiCardanoRegistrationStakeAddressGet( + stakeAddress: stakeAddress, + network: network?.value?.toString(), + slotNumber: slotNumber); + } + + ///Get registration info. + ///@param stake_address The stake address of the user. Should a valid Bech32 encoded address followed by the https://cips.cardano.org/cip/CIP-19/#stake-addresses. + ///@param network 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. + ///@param slot_number Slot number at which the staked ada amount should be calculated. If omitted latest slot number is used. + @Get(path: '/api/cardano/registration/{stake_address}') + Future> + _apiCardanoRegistrationStakeAddressGet({ + @Path('stake_address') required String? stakeAddress, + @Query('network') String? network, + @Query('slot_number') int? slotNumber, + }); + ///Get Cardano follower's sync state. ///@param network Cardano network type. If omitted `mainnet` network type is defined. 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. Future> apiCardanoSyncStateGet(