diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 9288b68c6b9..a61c56ea5a4 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -14,6 +14,7 @@ Arbritrary ARCHS ARGB Arissara +asat asmjs asyncio asyncpg @@ -241,6 +242,7 @@ rustflags rustfmt rustls rxdart +ryszard-schossler saibatizoku Schemathesis Scripthash @@ -286,7 +288,6 @@ trailings TXNZD txos Typer -ryszard-schossler unawaited unchunk Unlogged diff --git a/catalyst-gateway/.gitignore b/catalyst-gateway/.gitignore index fea35a6a809..699900d6cce 100644 --- a/catalyst-gateway/.gitignore +++ b/catalyst-gateway/.gitignore @@ -13,4 +13,5 @@ target/ # Build artifacts cat-gateway.coverage.info cat-gateway.junit-report.xml -cat-gateway-api.* \ No newline at end of file +cat-gateway-api.* +expanded.rs \ No newline at end of file diff --git a/catalyst-gateway/Justfile b/catalyst-gateway/Justfile index 9d16e79ea20..32ae8fa693f 100644 --- a/catalyst-gateway/Justfile +++ b/catalyst-gateway/Justfile @@ -57,7 +57,22 @@ run-cat-gateway-mainnet: build-cat-gateway RUST_LOG="error,cat_gateway=debug,cardano_chain_follower=debug,mithril-client=debug" \ ./target/release/cat-gateway run --log-level debug -# Do the minimal work needed to test the schema generated by cat-gateway -quick-schema-lint: build-cat-gateway +# expand all macros and produce a single unified source file. +expand-macros: + just bin/expand-macros + +# Generate the current openapi schema file locally. +generate-openapi-schema: build-cat-gateway ./target/release/cat-gateway docs cat-gateway-api.json - docker run --rm -it -v $(pwd):/tmp stoplight/spectral:latest lint --ruleset "/tmp/tests/.oapi-v3.spectral.yml" "/tmp/cat-gateway-api.json" \ No newline at end of file + +# Lint an openapi schema that has already been generated +lint-generated-schema: + docker run --rm -it -v $(pwd):/tmp stoplight/spectral:latest lint --ruleset "/tmp/tests/openapi-v3.0-lints/.spectral.yml" "/tmp/cat-gateway-api.json" + +# Lint an openapi schema that has already been generated locally. +# Make sure before running this command, you have installed "spectral" locally. +lint-generated-schema-local: + spectral lint --ruleset "./tests/openapi-v3.0-lints/.spectral.yml" "cat-gateway-api.json" + +# Do the minimal work needed to test the schema generated by cat-gateway +quick-schema-lint: generate-openapi-schema lint-generated-schema diff --git a/catalyst-gateway/bin/Cargo.toml b/catalyst-gateway/bin/Cargo.toml index ca17f018695..32a0293c373 100644 --- a/catalyst-gateway/bin/Cargo.toml +++ b/catalyst-gateway/bin/Cargo.toml @@ -15,7 +15,7 @@ repository.workspace = true workspace = true [dependencies] -cardano-chain-follower = { version = "0.0.4", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "2024-10-15-01" } +cardano-chain-follower = { version = "0.0.5", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "cardano-chain-follower-v0.0.5" } c509-certificate = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "v0.0.3" } pallas = { version = "0.30.1", git = "https://github.com/input-output-hk/catalyst-pallas.git", rev = "9b5183c8b90b90fe2cc319d986e933e9518957b3" } @@ -92,6 +92,7 @@ der-parser = "9.0.0" jsonschema = "0.26.1" bech32 = "0.11.0" const_format = "0.2.33" +regex = "1.11.1" [dev-dependencies] proptest = "1.5.0" diff --git a/catalyst-gateway/bin/Justfile b/catalyst-gateway/bin/Justfile new file mode 100644 index 00000000000..1c02f5933f6 --- /dev/null +++ b/catalyst-gateway/bin/Justfile @@ -0,0 +1,12 @@ +# use with https://github.com/casey/just +# +# Developer convenience functions + +# cspell: words prereqs, commitlog, rustls, nocapture + +default: + @just --list --unsorted + +# expand all macros and produce a single unified source file. +expand-macros: + cargo expand --release --bin cat-gateway > ../expanded.rs diff --git a/catalyst-gateway/bin/src/db/index/block/txo/cql/insert_txo_asset.cql b/catalyst-gateway/bin/src/db/index/block/txo/cql/insert_txo_asset.cql index 3bdb6342c4b..92c9dd0c534 100644 --- a/catalyst-gateway/bin/src/db/index/block/txo/cql/insert_txo_asset.cql +++ b/catalyst-gateway/bin/src/db/index/block/txo/cql/insert_txo_asset.cql @@ -6,7 +6,7 @@ INSERT INTO txo_assets_by_stake ( txn, txo, policy_id, - policy_name, + asset_name, value ) VALUES ( :stake_address, @@ -14,6 +14,6 @@ INSERT INTO txo_assets_by_stake ( :txn, :txo, :policy_id, - :policy_name, + :asset_name, :value ); diff --git a/catalyst-gateway/bin/src/db/index/block/txo/cql/insert_unstaked_txo_asset.cql b/catalyst-gateway/bin/src/db/index/block/txo/cql/insert_unstaked_txo_asset.cql index e170a0b46c2..6045229d69e 100644 --- a/catalyst-gateway/bin/src/db/index/block/txo/cql/insert_unstaked_txo_asset.cql +++ b/catalyst-gateway/bin/src/db/index/block/txo/cql/insert_unstaked_txo_asset.cql @@ -3,7 +3,7 @@ INSERT INTO unstaked_txo_assets_by_txn_hash ( txn_hash, txo, policy_id, - policy_name, + asset_name, slot_no, txn, value @@ -11,7 +11,7 @@ INSERT INTO unstaked_txo_assets_by_txn_hash ( :txn_hash, :txo, :policy_id, - :policy_name, + :asset_name, :slot_no, :txn, :value diff --git a/catalyst-gateway/bin/src/db/index/block/txo/insert_txo_asset.rs b/catalyst-gateway/bin/src/db/index/block/txo/insert_txo_asset.rs index ba7bbde7c45..69e547594a7 100644 --- a/catalyst-gateway/bin/src/db/index/block/txo/insert_txo_asset.rs +++ b/catalyst-gateway/bin/src/db/index/block/txo/insert_txo_asset.rs @@ -27,8 +27,8 @@ pub(super) struct Params { txo: i16, /// Policy hash of the asset policy_id: Vec, - /// Policy name of the asset - policy_name: String, + /// Name of the asset, within the Policy. + asset_name: Vec, /// Value of the asset value: num_bigint::BigInt, } @@ -41,7 +41,7 @@ impl Params { #[allow(clippy::too_many_arguments)] pub(super) fn new( stake_address: &[u8], slot_no: u64, txn: i16, txo: i16, policy_id: &[u8], - policy_name: &str, value: i128, + asset_name: &[u8], value: i128, ) -> Self { Self { stake_address: stake_address.to_vec(), @@ -49,7 +49,7 @@ impl Params { txn, txo, policy_id: policy_id.to_vec(), - policy_name: policy_name.to_owned(), + asset_name: asset_name.to_vec(), value: value.into(), } } diff --git a/catalyst-gateway/bin/src/db/index/block/txo/insert_unstaked_txo_asset.rs b/catalyst-gateway/bin/src/db/index/block/txo/insert_unstaked_txo_asset.rs index 250ca8ae1c3..7436d36d200 100644 --- a/catalyst-gateway/bin/src/db/index/block/txo/insert_unstaked_txo_asset.rs +++ b/catalyst-gateway/bin/src/db/index/block/txo/insert_unstaked_txo_asset.rs @@ -24,7 +24,7 @@ pub(super) struct Params { /// Policy hash of the asset policy_id: Vec, /// Policy name of the asset - policy_name: String, + asset_name: Vec, /// Block Slot Number slot_no: num_bigint::BigInt, /// Transaction Offset inside the block. @@ -40,14 +40,14 @@ impl Params { /// values. #[allow(clippy::too_many_arguments)] pub(super) fn new( - txn_hash: &[u8], txo: i16, policy_id: &[u8], policy_name: &str, slot_no: u64, txn: i16, + txn_hash: &[u8], txo: i16, policy_id: &[u8], asset_name: &[u8], slot_no: u64, txn: i16, value: i128, ) -> Self { Self { txn_hash: txn_hash.to_vec(), txo, policy_id: policy_id.to_vec(), - policy_name: policy_name.to_owned(), + asset_name: asset_name.to_vec(), slot_no: slot_no.into(), txn, value: value.into(), diff --git a/catalyst-gateway/bin/src/db/index/block/txo/mod.rs b/catalyst-gateway/bin/src/db/index/block/txo/mod.rs index 66bd950822f..f3f08440e60 100644 --- a/catalyst-gateway/bin/src/db/index/block/txo/mod.rs +++ b/catalyst-gateway/bin/src/db/index/block/txo/mod.rs @@ -181,7 +181,7 @@ impl TxoInsertQuery { let policy_id = asset.policy().to_vec(); for policy_asset in asset.assets() { if policy_asset.is_output() { - let policy_name = policy_asset.to_ascii_name().unwrap_or_default(); + let asset_name = policy_asset.name(); let value = policy_asset.any_coin(); if staked { @@ -191,19 +191,13 @@ impl TxoInsertQuery { txn, txo_index, &policy_id, - &policy_name, + asset_name, value, ); self.staked_txo_asset.push(params); } else { let params = insert_unstaked_txo_asset::Params::new( - txn_hash, - txo_index, - &policy_id, - &policy_name, - slot_no, - txn, - value, + txn_hash, txo_index, &policy_id, asset_name, slot_no, txn, value, ); self.unstaked_txo_asset.push(params); } diff --git a/catalyst-gateway/bin/src/db/index/queries/cql/get_assets_by_stake_address.cql b/catalyst-gateway/bin/src/db/index/queries/cql/get_assets_by_stake_address.cql index 128bc81d782..82d4c605818 100644 --- a/catalyst-gateway/bin/src/db/index/queries/cql/get_assets_by_stake_address.cql +++ b/catalyst-gateway/bin/src/db/index/queries/cql/get_assets_by_stake_address.cql @@ -3,7 +3,7 @@ SELECT txo, slot_no, policy_id, - policy_name, + asset_name, value FROM txo_assets_by_stake WHERE stake_address = :stake_address diff --git a/catalyst-gateway/bin/src/db/index/queries/staked_ada/get_assets_by_stake_address.rs b/catalyst-gateway/bin/src/db/index/queries/staked_ada/get_assets_by_stake_address.rs index 03d1a16a7dc..09bc8f2df03 100644 --- a/catalyst-gateway/bin/src/db/index/queries/staked_ada/get_assets_by_stake_address.rs +++ b/catalyst-gateway/bin/src/db/index/queries/staked_ada/get_assets_by_stake_address.rs @@ -54,7 +54,7 @@ mod result { /// Asset hash. pub policy_id: Vec, /// Asset name. - pub policy_name: String, + pub asset_name: Vec, /// Asset value. pub value: num_bigint::BigInt, } diff --git a/catalyst-gateway/bin/src/db/index/schema/cql/txo_assets_by_stake_table.cql b/catalyst-gateway/bin/src/db/index/schema/cql/txo_assets_by_stake_table.cql index 19a686f2d53..da85a1d9323 100644 --- a/catalyst-gateway/bin/src/db/index/schema/cql/txo_assets_by_stake_table.cql +++ b/catalyst-gateway/bin/src/db/index/schema/cql/txo_assets_by_stake_table.cql @@ -7,11 +7,11 @@ CREATE TABLE IF NOT EXISTS txo_assets_by_stake ( txn smallint, -- Which Transaction in the Slot is the TXO. txo smallint, -- offset in the txo list of the transaction the txo is in. policy_id blob, -- asset policy hash (id) (28 byte binary hash) - policy_name text, -- name of the policy (UTF8) TODO: https://github.com/input-output-hk/catalyst-voices/issues/1121 + asset_name blob, -- name of the asset policy (UTF8) (32 bytes) -- None Key Data of the asset. value varint, -- Value of the asset (i128) - PRIMARY KEY (stake_address, slot_no, txn, txo, policy_id, policy_name) + PRIMARY KEY (stake_address, slot_no, txn, txo, policy_id, asset_name) ); diff --git a/catalyst-gateway/bin/src/db/index/schema/cql/unstaked_txo_assets_by_txn_hash.cql b/catalyst-gateway/bin/src/db/index/schema/cql/unstaked_txo_assets_by_txn_hash.cql index 5a327227593..c3dffcfbd75 100644 --- a/catalyst-gateway/bin/src/db/index/schema/cql/unstaked_txo_assets_by_txn_hash.cql +++ b/catalyst-gateway/bin/src/db/index/schema/cql/unstaked_txo_assets_by_txn_hash.cql @@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS unstaked_txo_assets_by_txn_hash ( txn_hash blob, -- 32 byte hash of this transaction. txo smallint, -- offset in the txo list of the transaction the txo is in. policy_id blob, -- asset policy hash (id) (28 byte binary hash) - policy_name text, -- name of the policy (UTF8) + asset_name blob, -- name of the policy (UTF8) (32 bytes) -- Secondary Location information for the transaction. slot_no varint, -- slot number the txo was created in. @@ -13,5 +13,5 @@ CREATE TABLE IF NOT EXISTS unstaked_txo_assets_by_txn_hash ( -- Value of the asset. value varint, -- Value of the asset (u64) - PRIMARY KEY (txn_hash, txo, policy_id, policy_name) + PRIMARY KEY (txn_hash, txo, policy_id, asset_name) ); diff --git a/catalyst-gateway/bin/src/db/index/schema/mod.rs b/catalyst-gateway/bin/src/db/index/schema/mod.rs index 8a219105fe1..e262e2a0a76 100644 --- a/catalyst-gateway/bin/src/db/index/schema/mod.rs +++ b/catalyst-gateway/bin/src/db/index/schema/mod.rs @@ -17,7 +17,7 @@ use crate::{settings::cassandra_db, utils::blake2b_hash::generate_uuid_string_fr /// change accidentally, and is NOT to be used directly to set the schema version of the /// table namespaces. #[allow(dead_code)] -const SCHEMA_VERSION: &str = "08193dfe-698a-8177-bdf8-20c5691a06e7"; +const SCHEMA_VERSION: &str = "75ae6ac9-ddd8-8472-8a7a-8676d04f8679"; /// Keyspace Create (Templated) const CREATE_NAMESPACE_CQL: &str = include_str!("./cql/namespace.cql"); diff --git a/catalyst-gateway/bin/src/service/api/cardano/cip36/endpoint.rs b/catalyst-gateway/bin/src/service/api/cardano/cip36/endpoint.rs new file mode 100644 index 00000000000..cfd5d5f2a51 --- /dev/null +++ b/catalyst-gateway/bin/src/service/api/cardano/cip36/endpoint.rs @@ -0,0 +1,32 @@ +//! Implementation of the GET `/cardano/cip36` endpoint + +use std::time::Duration; + +use poem::http::HeaderMap; +use tokio::time::sleep; + +use super::{ + cardano::{self}, + response, NoneOrRBAC, SlotNo, +}; +use crate::service::common::{self}; + +/// Process the endpoint operation +pub(crate) async fn cip36_registrations( + _lookup: Option, _asat: Option, + _page: common::types::generic::query::pagination::Page, + _limit: common::types::generic::query::pagination::Limit, _auth: NoneOrRBAC, + _headers: &HeaderMap, +) -> response::AllRegistration { + // Dummy sleep, remove it + sleep(Duration::from_millis(1)).await; + + // Todo: refactor the below into a single operation here. + + // If _asat is None, then get the latest slot number from the chain follower and use that. + // If _for is not defined, use the stake addresses defined for Role0 in the _auth + // parameter. _auth not yet implemented, so put placeholder for that, and return not + // found until _auth is implemented. + + response::Cip36Registration::NotFound.into() +} diff --git a/catalyst-gateway/bin/src/service/api/cardano/cip36/mod.rs b/catalyst-gateway/bin/src/service/api/cardano/cip36/mod.rs new file mode 100644 index 00000000000..549dc776443 --- /dev/null +++ b/catalyst-gateway/bin/src/service/api/cardano/cip36/mod.rs @@ -0,0 +1,140 @@ +//! CIP36 Registration Endpoints + +use ed25519_dalek::VerifyingKey; +use poem::http::{HeaderMap, StatusCode}; +use poem_openapi::{param::Query, OpenApi}; + +use self::cardano::slot_no::SlotNo; +use super::Ed25519HexEncodedPublicKey; +use crate::service::common::{ + self, + auth::none_or_rbac::NoneOrRBAC, + tags::ApiTags, + types::cardano::{self}, +}; + +pub(crate) mod endpoint; +pub(crate) mod old_endpoint; +pub(crate) mod response; + +/// Cardano Staking API Endpoints +pub(crate) struct Api; + +#[OpenApi(tag = "ApiTags::Cardano")] +impl Api { + /// CIP36 registrations. + /// + /// This endpoint gets the latest registration given either the voting key, stake + /// address, stake public key or the auth token. + /// + /// Registration can be the latest to date, or at a particular date-time or slot + /// number. + // Required To be able to look up for: + // 1. Voting Public Key + // 2. Cip-19 stake address + // 3. All - Hidden option and would need a hidden api key header (used to create a snapshot + // replacement.) + // 4. Stake addresses associated with current Role0 registration (if none of the above + // provided). + // If none of the above provided, return not found. + #[oai( + path = "/draft/cardano/registration/cip36", + method = "get", + operation_id = "cardanoRegistrationCip36" + )] + async fn get_registration( + &self, lookup: Query>, + asat: Query>, + page: Query>, + limit: Query>, + /// No Authorization required, but Token permitted. + auth: NoneOrRBAC, + /// Headers, used if the query is requesting ALL to determine if the secret API + /// Key is also defined. + headers: &HeaderMap, + ) -> response::AllRegistration { + // Special validation for the `lookup` parameter. + // If the parameter is ALL, BUT we do not have a valid API Key, just report the parameter + // is invalid. + if let Some(lookup) = lookup.0.clone() { + if lookup.is_all(headers).is_err() { + return response::AllRegistration::unprocessable_content(vec![ + poem::Error::from_string( + "Invalid Stake Address or Voter Key", + StatusCode::UNPROCESSABLE_ENTITY, + ), + ]); + } + } + + endpoint::cip36_registrations( + lookup.0, + SlotNo::into_option(asat.0), + page.0.unwrap_or_default(), + limit.0.unwrap_or_default(), + auth, + headers, + ) + .await + } + + /// Get latest CIP36 registrations from stake address. + /// + /// This endpoint gets the latest registration given a stake address. + #[oai( + path = "/draft/cardano/cip36/latest_registration/stake_addr", + method = "get", + operation_id = "latestRegistrationGivenStakeAddr" + )] + async fn latest_registration_cip36_given_stake_addr( + &self, + /// Stake Public Key to find the latest registration for. + stake_pub_key: Query, // Validation provided by type. + /// No Authorization required, but Token permitted. + _auth: NoneOrRBAC, + ) -> old_endpoint::SingleRegistrationResponse { + let hex_key = stake_pub_key.0; + let pub_key: VerifyingKey = hex_key.into(); + + old_endpoint::get_latest_registration_from_stake_addr(&pub_key, true).await + } + + /// Get latest CIP36 registrations from a stake key hash. + /// + /// This endpoint gets the latest registration given a stake key hash. + #[oai( + path = "/draft/cardano/cip36/latest_registration/stake_key_hash", + method = "get", + operation_id = "latestRegistrationGivenStakeHash" + )] + async fn latest_registration_cip36_given_stake_key_hash( + &self, + /// Stake Key Hash to find the latest registration for. + #[oai(validator(max_length = 66, min_length = 0, pattern = "[0-9a-f]"))] + stake_key_hash: Query, + /// No Authorization required, but Token permitted. + _auth: NoneOrRBAC, + ) -> old_endpoint::SingleRegistrationResponse { + old_endpoint::get_latest_registration_from_stake_key_hash(stake_key_hash.0, true).await + } + + /// Get latest CIP36 registrations from voting key. + /// + /// This endpoint returns the list of stake address registrations currently associated + /// with a given voting key. + #[oai( + path = "/draft/cardano/cip36/latest_registration/vote_key", + method = "get", + operation_id = "latestRegistrationGivenVoteKey" + )] + async fn latest_registration_cip36_given_vote_key( + &self, + /// Voting Key to find CIP36 registrations for. + #[oai(validator(max_length = 66, min_length = 66, pattern = "0x[0-9a-f]"))] + vote_key: Query, + /// No Authorization required, but Token permitted. + _auth: NoneOrRBAC, + ) -> old_endpoint::MultipleRegistrationResponse { + old_endpoint::get_associated_vote_key_registrations(vote_key.0, true).await + } +} diff --git a/catalyst-gateway/bin/src/service/api/cardano/cip36.rs b/catalyst-gateway/bin/src/service/api/cardano/cip36/old_endpoint.rs similarity index 100% rename from catalyst-gateway/bin/src/service/api/cardano/cip36.rs rename to catalyst-gateway/bin/src/service/api/cardano/cip36/old_endpoint.rs diff --git a/catalyst-gateway/bin/src/service/api/cardano/cip36/response.rs b/catalyst-gateway/bin/src/service/api/cardano/cip36/response.rs new file mode 100644 index 00000000000..fc9d0b5411a --- /dev/null +++ b/catalyst-gateway/bin/src/service/api/cardano/cip36/response.rs @@ -0,0 +1,166 @@ +//! Cip36 Registration Query Endpoint Response +use poem_openapi::{payload::Json, types::Example, ApiResponse, Object}; + +use crate::service::common; + +// ToDo: The examples of this response should be taken from representative data from a +// response generated on pre-prod. + +/// Endpoint responses. +#[derive(ApiResponse)] +#[allow(dead_code)] // TODO: Remove once endpoint fully implemented +pub(crate) enum Cip36Registration { + /// All CIP36 registrations associated with the same Voting Key. + #[oai(status = 200)] + Ok(Json), + /// No valid registration. + #[oai(status = 404)] + NotFound, +} + +/// All responses to a cip36 registration query +pub(crate) type AllRegistration = common::responses::WithErrorResponses; + +/// List of CIP36 Registration Data as found on-chain. +#[derive(Object)] +#[oai(example = true)] +pub(crate) struct Cip36RegistrationList { + /// The Slot the Registrations are valid up until. + /// + /// Any registrations that occurred after this Slot are not included in the list. + /// Errors are reported only if they fall between the last valid registration and this + /// slot number. + /// Earlier errors are never reported. + slot: common::types::cardano::slot_no::SlotNo, + /// List of registrations associated with the query. + #[oai(validator(max_items = "100"))] + voting_key: Vec, + /// List of latest invalid registrations that were found, for the requested filter. + #[oai(skip_serializing_if_is_empty, validator(max_items = "10"))] + invalid: Vec, + /// Current Page + page: common::objects::generic::pagination::CurrentPage, +} + +impl Example for Cip36RegistrationList { + fn example() -> Self { + Self { + slot: (common::types::cardano::slot_no::EXAMPLE + 635).into(), + voting_key: vec![Cip36RegistrationsForVotingPublicKey::example()], + invalid: vec![Cip36Details::invalid_example()], + page: common::objects::generic::pagination::CurrentPage::example(), + } + } +} + +/// List of CIP36 Registration Data for a Voting Key. +#[derive(Object)] +#[oai(example = true)] +pub(crate) struct Cip36RegistrationsForVotingPublicKey { + /// Voting Public Key + pub vote_pub_key: common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey, + /// List of Registrations associated with this Voting Key + #[oai(validator(max_items = "100"))] + pub registrations: Vec, +} + +impl Example for Cip36RegistrationsForVotingPublicKey { + fn example() -> Self { + Cip36RegistrationsForVotingPublicKey { + vote_pub_key: + common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey::example(), + registrations: vec![Cip36Details::example()], + } + } +} + +/// CIP36 Registration Data as found on-chain. +#[derive(Object)] +#[oai(example = true)] +pub(crate) struct Cip36Details { + /// Blocks Slot Number that the registration certificate is in. + pub slot_no: common::types::cardano::slot_no::SlotNo, + /// Full Stake Address (not hashed, 32 byte ED25519 Public key). + #[oai(skip_serializing_if_is_none)] + pub stake_pub_key: + Option, + /// Voting Public Key (Ed25519 Public key). + #[oai(skip_serializing_if_is_none)] + pub vote_pub_key: + Option, + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented. + #[oai(skip_serializing_if_is_none)] + pub nonce: Option, + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented. + #[oai(skip_serializing_if_is_none)] + pub txn: Option, + /// Cardano Cip-19 Formatted Shelley Payment Address. + #[oai(skip_serializing_if_is_none)] + pub payment_address: Option, + /// If the payment address is a script, then it can not be payed rewards. + #[oai(default = "is_payable_default")] + pub is_payable: bool, + /// If this field is set, then the registration was in CIP15 format. + #[oai(default = "cip15_default")] + pub cip15: bool, + /// If there are errors with this registration, they are listed here. + /// This field is *NEVER* returned for a valid registration. + #[oai( + default = "Vec::::new", + skip_serializing_if_is_empty, + validator(max_items = "10") + )] + pub errors: Vec, +} + +/// Is the payment address payable by catalyst. +fn is_payable_default() -> bool { + true +} + +/// Is the registration using CIP15 format. +fn cip15_default() -> bool { + false +} + +impl Example for Cip36Details { + /// Example of a valid registration + fn example() -> Self { + Self { + slot_no: common::types::cardano::slot_no::SlotNo::example(), + stake_pub_key: Some( + common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey::examples(0), + ), + vote_pub_key: Some( + common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey::example(), + ), + nonce: Some(common::types::cardano::nonce::Nonce::example()), + txn: Some(common::types::cardano::txn_index::TxnIndex::example()), + payment_address: Some( + common::types::cardano::cip19_shelley_address::Cip19ShelleyAddress::example(), + ), + is_payable: true, + cip15: false, + errors: Vec::::new(), + } + } +} + +impl Cip36Details { + /// Example of an invalid registration + fn invalid_example() -> Self { + Self { + slot_no: (common::types::cardano::slot_no::EXAMPLE + 135).into(), + stake_pub_key: None, + vote_pub_key: Some( + common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey::example(), + ), + nonce: Some((common::types::cardano::nonce::EXAMPLE + 97).into()), + txn: Some(common::types::cardano::txn_index::TxnIndex::example()), + payment_address: None, + is_payable: false, + cip15: true, + errors: vec!["Stake Public Key is required".into()], + } + } +} diff --git a/catalyst-gateway/bin/src/service/api/cardano/mod.rs b/catalyst-gateway/bin/src/service/api/cardano/mod.rs index 059611067c0..6866e65b051 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/mod.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/mod.rs @@ -1,10 +1,9 @@ //! Cardano API endpoints -use ed25519_dalek::VerifyingKey; use poem_openapi::{ param::{Path, Query}, OpenApi, }; -use types::{DateTime, SlotNumber}; +use types::DateTime; use crate::service::{ common::{ @@ -12,17 +11,17 @@ use crate::service::{ objects::cardano::network::Network, tags::ApiTags, types::{ - cardano::address::Cip19StakeAddress, + cardano::cip19_stake_address::Cip19StakeAddress, generic::ed25519_public_key::Ed25519HexEncodedPublicKey, }, }, utilities::middleware::schema_validation::schema_version_validation, }; -mod cip36; +pub(crate) mod cip36; mod date_time_to_slot_number_get; mod rbac; -mod registration_get; +// mod registration_get; pub(crate) mod staking; mod sync_state_get; pub(crate) mod types; @@ -32,40 +31,6 @@ pub(crate) struct Api; #[OpenApi(tag = "ApiTags::Cardano")] impl Api { - /// 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. - #[oai( - path = "/draft/cardano/registration/:stake_address", - method = "get", - operation_id = "registrationGet", - transform = "schema_version_validation" - )] - async fn registration_get( - &self, - /// The stake address of the user. - /// Should be 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>, - /// No Authorization required, but Token permitted. - _auth: NoneOrRBAC, - ) -> registration_get::AllResponses { - registration_get::endpoint(stake_address.0, network.0, slot_number.0).await - } - /// Get Cardano follower's sync state. /// /// This endpoint returns the current cardano follower's sync state info. @@ -116,66 +81,6 @@ impl Api { date_time_to_slot_number_get::endpoint(date_time.0, network.0).await } - /// Get latest CIP36 registrations from stake address. - /// - /// This endpoint gets the latest registration given a stake address. - #[oai( - path = "/draft/cardano/cip36/latest_registration/stake_addr", - method = "get", - operation_id = "latestRegistrationGivenStakeAddr" - )] - async fn latest_registration_cip36_given_stake_addr( - &self, - /// Stake Public Key to find the latest registration for. - stake_pub_key: Query, // Validation provided by type. - /// No Authorization required, but Token permitted. - _auth: NoneOrRBAC, - ) -> cip36::SingleRegistrationResponse { - let hex_key = stake_pub_key.0; - let pub_key: VerifyingKey = hex_key.into(); - - cip36::get_latest_registration_from_stake_addr(&pub_key, true).await - } - - /// Get latest CIP36 registrations from a stake key hash. - /// - /// This endpoint gets the latest registration given a stake key hash. - #[oai( - path = "/draft/cardano/cip36/latest_registration/stake_key_hash", - method = "get", - operation_id = "latestRegistrationGivenStakeHash" - )] - async fn latest_registration_cip36_given_stake_key_hash( - &self, - /// Stake Key Hash to find the latest registration for. - #[oai(validator(max_length = 66, min_length = 0, pattern = "[0-9a-f]"))] - stake_key_hash: Query, - /// No Authorization required, but Token permitted. - _auth: NoneOrRBAC, - ) -> cip36::SingleRegistrationResponse { - cip36::get_latest_registration_from_stake_key_hash(stake_key_hash.0, true).await - } - - /// Get latest CIP36 registrations from voting key. - /// - /// This endpoint returns the list of stake address registrations currently associated - /// with a given voting key. - #[oai( - path = "/draft/cardano/cip36/latest_registration/vote_key", - method = "get", - operation_id = "latestRegistrationGivenVoteKey" - )] - async fn latest_registration_cip36_given_vote_key( - &self, - /// Voting Key to find CIP36 registrations for. - #[oai(validator(max_length = 66, min_length = 66, pattern = "0x[0-9a-f]"))] - vote_key: Query, - /// No Authorization required, but Token permitted. - _auth: NoneOrRBAC, - ) -> cip36::MultipleRegistrationResponse { - cip36::get_associated_vote_key_registrations(vote_key.0, true).await - } - #[oai( path = "/draft/rbac/chain_root/:stake_address", method = "get", @@ -234,4 +139,4 @@ impl Api { } /// Cardano API Endpoints -pub(crate) type CardanoApi = (Api, staking::Api); +pub(crate) type CardanoApi = (Api, staking::Api, cip36::Api); diff --git a/catalyst-gateway/bin/src/service/api/cardano/rbac/chain_root_get.rs b/catalyst-gateway/bin/src/service/api/cardano/rbac/chain_root_get.rs index 2769b1c2ed7..42e7de091b0 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/rbac/chain_root_get.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/rbac/chain_root_get.rs @@ -12,7 +12,9 @@ use crate::{ }, service::common::{ responses::WithErrorResponses, - types::{cardano::address::Cip19StakeAddress, headers::retry_after::RetryAfterOption}, + types::{ + cardano::cip19_stake_address::Cip19StakeAddress, headers::retry_after::RetryAfterOption, + }, }, }; diff --git a/catalyst-gateway/bin/src/service/api/cardano/staking/assets_get.rs b/catalyst-gateway/bin/src/service/api/cardano/staking/assets_get.rs index d1d2b72126e..f92fa93d5d9 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/staking/assets_get.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/staking/assets_get.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use anyhow::anyhow; use futures::StreamExt; +use pallas::ledger::addresses::StakeAddress; use poem_openapi::{payload::Json, ApiResponse}; use super::SlotNumber; @@ -26,7 +27,7 @@ use crate::{ stake_info::{FullStakeInfo, StakeInfo, StakedNativeTokenInfo}, }, responses::WithErrorResponses, - types::cardano::address::Cip19StakeAddress, + types::cardano::{asset_name::AssetName, cip19_stake_address::Cip19StakeAddress}, }, }; @@ -78,8 +79,7 @@ struct TxoAssetInfo { /// Asset hash. id: Vec, /// Asset name. - // TODO: https://github.com/input-output-hk/catalyst-voices/issues/1121 - name: String, + name: AssetName, /// Asset amount. amount: num_bigint::BigInt, } @@ -99,8 +99,7 @@ struct TxoInfo { /// Whether the TXO was spent. spent_slot_no: Option, /// TXO assets. - // TODO: https://github.com/input-output-hk/catalyst-voices/issues/1121 - assets: HashMap, TxoAssetInfo>, + assets: HashMap, Vec>, } /// Calculate the stake info for a given stake address. @@ -114,7 +113,7 @@ async fn calculate_stake_info( anyhow::bail!("Failed to acquire db session"); }; - let address = stake_address.to_stake_address()?; + let address: StakeAddress = stake_address.try_into()?; let stake_address_bytes = address.payload().as_hash().to_vec(); let mut txos_by_txn = get_txo_by_txn(&session, stake_address_bytes.clone(), slot_num).await?; @@ -185,12 +184,18 @@ async fn get_txo_by_txn( let entry = txo_info .assets .entry(row.policy_id.clone()) - .or_insert(TxoAssetInfo { - id: row.policy_id, - name: row.policy_name, - amount: num_bigint::BigInt::ZERO, - }); - entry.amount += row.value; + .or_insert_with(Vec::new); + + match entry.iter_mut().find(|item| item.id == row.policy_id) { + Some(item) => item.amount += row.value, + None => { + entry.push(TxoAssetInfo { + id: row.policy_id, + name: row.asset_name.into(), + amount: row.value, + }); + }, + } } let mut txos_by_txn = HashMap::new(); @@ -273,10 +278,10 @@ fn build_stake_info( stake_info.ada_amount += i64::try_from(txo_info.value).map_err(|err| anyhow!(err))?; - for asset in txo_info.assets.into_values() { + for asset in txo_info.assets.into_values().flatten() { stake_info.native_tokens.push(StakedNativeTokenInfo { policy_hash: asset.id.try_into()?, - asset_name: asset.name.into(), + asset_name: asset.name, amount: asset.amount.try_into()?, }); } diff --git a/catalyst-gateway/bin/src/service/api/cardano/staking/mod.rs b/catalyst-gateway/bin/src/service/api/cardano/staking/mod.rs index 7e3d9daf6ae..704b4d63264 100644 --- a/catalyst-gateway/bin/src/service/api/cardano/staking/mod.rs +++ b/catalyst-gateway/bin/src/service/api/cardano/staking/mod.rs @@ -9,7 +9,7 @@ use super::types::SlotNumber; use crate::service::{ common::{ auth::none_or_rbac::NoneOrRBAC, objects::cardano::network::Network, tags::ApiTags, - types::cardano::address::Cip19StakeAddress, + types::cardano::cip19_stake_address::Cip19StakeAddress, }, utilities::middleware::schema_validation::schema_version_validation, }; diff --git a/catalyst-gateway/bin/src/service/api/mod.rs b/catalyst-gateway/bin/src/service/api/mod.rs index 3d1e75f9314..0df680295f7 100644 --- a/catalyst-gateway/bin/src/service/api/mod.rs +++ b/catalyst-gateway/bin/src/service/api/mod.rs @@ -54,7 +54,7 @@ pub(crate) fn mk_api() -> OpenApiService<(HealthApi, CardanoApi, ConfigApi, Lega let mut service = OpenApiService::new( ( HealthApi, - (cardano::Api, cardano::staking::Api), + (cardano::Api, cardano::staking::Api, cardano::cip36::Api), ConfigApi, (legacy::RegistrationApi, legacy::V0Api, legacy::V1Api), ), diff --git a/catalyst-gateway/bin/src/service/common/auth/api_key.rs b/catalyst-gateway/bin/src/service/common/auth/api_key.rs index 7264e7906aa..4abc18efb98 100644 --- a/catalyst-gateway/bin/src/service/common/auth/api_key.rs +++ b/catalyst-gateway/bin/src/service/common/auth/api_key.rs @@ -4,16 +4,20 @@ //! //! It is NOT to be used on any endpoint intended to be publicly facing. -use poem::Request; +use anyhow::{bail, Result}; +use poem::{http::HeaderMap, Request}; use poem_openapi::{auth::ApiKey, SecurityScheme}; use crate::settings::Settings; +/// The header name that holds the API Key +const API_KEY_HEADER: &str = "X-API-Key"; + /// `ApiKey` authorization for Internal Endpoints #[derive(SecurityScheme)] #[oai( ty = "api_key", - key_name = "X-API-Key", + key_name = "X-API-Key", // MUST match the above constant. key_in = "header", checker = "api_checker" )] @@ -29,3 +33,14 @@ async fn api_checker(_req: &Request, api_key: ApiKey) -> Option { None } } + +/// Check if the API Key is correctly set. +/// Returns an error if it is not. +pub(crate) fn check_api_key(headers: &HeaderMap) -> Result<()> { + if let Some(key) = headers.get(API_KEY_HEADER) { + if Settings::check_internal_api_key(key.to_str()?) { + return Ok(()); + } + } + bail!("Invalid API Key"); +} diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/cip36.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/cip36.rs index 9144a43a623..707d1e5631b 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/cip36.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/cip36.rs @@ -1,5 +1,8 @@ //! CIP36 object +// TODO: This is NOT common, remove it once the rationalized endpoint is implemented. +// Retained to keep the existing code from breaking only. + use poem_openapi::{types::Example, Object}; use crate::service::common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey; diff --git a/catalyst-gateway/bin/src/service/common/objects/cardano/hash.rs b/catalyst-gateway/bin/src/service/common/objects/cardano/hash.rs index 295c4409a40..d7cc7daae6f 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/hash.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/hash.rs @@ -5,7 +5,7 @@ use poem_openapi::{ types::{ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, }; -use crate::service::utilities::to_hex_with_prefix; +use crate::service::utilities::as_hex_string; /// Cardano Blake2b256 hash encoded in hex. #[derive(Debug)] @@ -91,6 +91,6 @@ impl ParseFromJSON for Hash { impl ToJSON for Hash { fn to_json(&self) -> Option { - Some(serde_json::Value::String(to_hex_with_prefix(&self.0))) + Some(serde_json::Value::String(as_hex_string(&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 63159b676c9..391e5e16e4b 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,10 @@ -//! Defines API schemas of Cardano types. +//! Defines API schemas of Cardano Objects. +//! +//! These Objects MUST be used in multiple places for multiple things to be considered +//! common. They should not be simple types. but actual objects. +//! Simple types belong in `common/types`. -pub(crate) mod cip36; +pub(crate) mod cip36; // TODO: Not common, to be removed once code refactored. pub(crate) mod hash; pub(crate) mod network; pub(crate) mod registration_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 index cc23ffb0f68..24eeb2cb09b 100644 --- a/catalyst-gateway/bin/src/service/common/objects/cardano/registration_info.rs +++ b/catalyst-gateway/bin/src/service/common/objects/cardano/registration_info.rs @@ -5,7 +5,7 @@ use poem_openapi::{types::Example, Object, Union}; use crate::service::{ api::cardano::types::{Nonce, PaymentAddress, PublicVotingInfo, TxId}, common::objects::cardano::hash::Hash, - utilities::to_hex_with_prefix, + utilities::as_hex_string, }; /// The Voting power and voting key of a Delegated voter. @@ -76,7 +76,7 @@ impl RegistrationInfo { let voting_info = match voting_info { PublicVotingInfo::Direct(voting_key) => { VotingInfo::Direct(DirectVoter { - voting_key: to_hex_with_prefix(voting_key.bytes()), + voting_key: as_hex_string(voting_key.bytes()), }) }, PublicVotingInfo::Delegated(delegations) => { @@ -85,7 +85,7 @@ impl RegistrationInfo { .into_iter() .map(|(voting_key, power)| { Delegation { - voting_key: to_hex_with_prefix(voting_key.bytes()), + voting_key: as_hex_string(voting_key.bytes()), power, } }) @@ -95,7 +95,7 @@ impl RegistrationInfo { }; Self { tx_hash: tx_hash.into(), - rewards_address: to_hex_with_prefix(rewards_address), + rewards_address: as_hex_string(rewards_address), nonce, voting_info, } 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 326d1633406..770dd19e68f 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 @@ -8,7 +8,7 @@ use crate::service::{ }; /// Cardano follower's sync state info. -#[derive(Debug, Object)] +#[derive(Object)] #[oai(example = true)] pub(crate) struct SyncState { /// Slot number. diff --git a/catalyst-gateway/bin/src/service/common/objects/generic/mod.rs b/catalyst-gateway/bin/src/service/common/objects/generic/mod.rs new file mode 100644 index 00000000000..9f5eec6f164 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/generic/mod.rs @@ -0,0 +1,3 @@ +//! Generic Objects + +pub(crate) mod pagination; diff --git a/catalyst-gateway/bin/src/service/common/objects/generic/pagination.rs b/catalyst-gateway/bin/src/service/common/objects/generic/pagination.rs new file mode 100644 index 00000000000..278fbc65d5d --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/objects/generic/pagination.rs @@ -0,0 +1,48 @@ +//! Pagination response object to be included in every paged response. + +use poem_openapi::{types::Example, Object}; + +use crate::service::common; + +/// Description for the `CurrentPage` object. +#[allow(dead_code)] +pub(crate) const CURRENT_PAGE_DESCRIPTION: &str = + "The Page of results is being returned, and the Limit of results. +The data returned is constrained by this limit. +The limit applies to the total number of records returned. +*Note: The Limit may not be exactly as requested, if it was constrained by the response. +The caller must read this record to ensure the correct data requested was returned.*"; + +#[derive(Object)] +#[oai(example = true)] +/// Current Page of data being returned. +pub(crate) struct CurrentPage { + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented + pub page: common::types::generic::query::pagination::Page, + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented + pub limit: common::types::generic::query::pagination::Limit, + #[allow(clippy::missing_docs_in_private_items)] // Type is pre documented + pub remaining: common::types::generic::query::pagination::Remaining, +} + +impl Example for CurrentPage { + fn example() -> Self { + Self { + page: common::types::generic::query::pagination::Page::example(), + limit: common::types::generic::query::pagination::Limit::example(), + remaining: common::types::generic::query::pagination::Remaining::example(), + } + } +} + +impl CurrentPage { + /// Create a new `CurrentPage` object. + #[allow(dead_code)] + fn new(page: u64, limit: u64, remaining: u64) -> Self { + Self { + page: page.into(), + limit: limit.into(), + remaining: remaining.into(), + } + } +} diff --git a/catalyst-gateway/bin/src/service/common/objects/mod.rs b/catalyst-gateway/bin/src/service/common/objects/mod.rs index 68c270166cf..c5e863052f3 100644 --- a/catalyst-gateway/bin/src/service/common/objects/mod.rs +++ b/catalyst-gateway/bin/src/service/common/objects/mod.rs @@ -2,4 +2,5 @@ pub(crate) mod cardano; pub(crate) mod config; +pub(crate) mod generic; pub(crate) mod legacy; diff --git a/catalyst-gateway/bin/src/service/common/responses/code_401_unauthorized.rs b/catalyst-gateway/bin/src/service/common/responses/code_401_unauthorized.rs index 962db861df8..445895f2bdd 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_401_unauthorized.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_401_unauthorized.rs @@ -3,32 +3,39 @@ use poem_openapi::{types::Example, Object}; use uuid::Uuid; -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. +use crate::service::common; + +#[derive(Object)] +#[oai(example)] +// Keep this message consistent with the response comment. +/// The client has not sent valid authentication credentials for the requested +/// resource. pub(crate) struct Unauthorized { /// Unique ID of this Server Error so that it can be located easily for debugging. id: Uuid, /// Error message. // Will not contain sensitive information, internal details or backtraces. - #[oai(validator(max_length = "1000", pattern = "^[0-9a-zA-Z].*$"))] - msg: String, + //#[oai(validator(max_length = "1000", pattern = "^[0-9a-zA-Z].*$"))] + msg: common::types::generic::error_msg::ErrorMessage, } impl Unauthorized { - /// Create a new Server Error Response Payload. + /// Create a new Payload. pub(crate) fn new(msg: Option) -> Self { let msg = msg.unwrap_or( "Your request was not successful because it lacks valid authentication credentials for the requested resource.".to_string(), ); let id = Uuid::new_v4(); - Self { id, msg } + Self { + id, + msg: msg.into(), + } } } impl Example for Unauthorized { - /// Example for the Too Many Requests Payload. + /// Example fn example() -> Self { Self::new(None) } diff --git a/catalyst-gateway/bin/src/service/common/responses/code_403_forbidden.rs b/catalyst-gateway/bin/src/service/common/responses/code_403_forbidden.rs index b087e2350a7..8ef89206dc9 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_403_forbidden.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_403_forbidden.rs @@ -3,9 +3,10 @@ use poem_openapi::{types::Example, Object}; use uuid::Uuid; -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. +#[derive(Object)] +#[oai(example)] +/// The client has not sent valid authentication credentials for the requested +/// resource. pub(crate) struct Forbidden { /// Unique ID of this Server Error so that it can be located easily for debugging. id: Uuid, diff --git a/catalyst-gateway/bin/src/service/common/responses/code_422_unprocessable_content.rs b/catalyst-gateway/bin/src/service/common/responses/code_422_unprocessable_content.rs index b37eb9cc9b6..e2b8061248e 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_422_unprocessable_content.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_422_unprocessable_content.rs @@ -2,32 +2,69 @@ use poem_openapi::{types::Example, Object}; -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] +use crate::service::common; + +#[derive(Object)] +#[oai(example)] +/// The client has not sent valid data in its request, headers, parameters or body. +pub(crate) struct UnprocessableContent { + #[oai(validator(max_items = "1000", min_items = "1"))] + /// Details of each error in the content that was detected. + /// + /// Note: This may not be ALL errors in the content, as validation of content can stop + /// at any point an error is detected. + detail: Vec, +} + +impl UnprocessableContent { + /// Create a new `ContentErrorDetail` Response Payload. + pub(crate) fn new(errors: Vec) -> Self { + let mut detail = vec![]; + for error in errors { + detail.push(ContentErrorDetail::new(&error)); + } + + Self { detail } + } +} + +impl Example for UnprocessableContent { + /// Example for the Too Many Requests Payload. + fn example() -> Self { + Self { + detail: vec![ContentErrorDetail::example()], + } + } +} + +//-------------------------------------------------------------------------------------- + +#[derive(Object)] +#[oai(example)] /// Individual details of a single error that was detected with the content of the /// request. pub(crate) struct ContentErrorDetail { /// The location of the error - #[oai(validator(max_items = 100, max_length = "1000", pattern = "^[0-9a-zA-Z].*$"))] - loc: Option>, + #[oai(validator(max_items = 100))] + loc: Option>, /// The error message. #[oai(validator(max_length = "1000", pattern = "^[0-9a-zA-Z].*$"))] - msg: Option, + msg: Option, /// The type of error #[oai( rename = "type", validator(max_length = "1000", pattern = "^[0-9a-zA-Z].*$") )] - err_type: Option, + err_type: Option, } impl Example for ContentErrorDetail { /// Example for the `ContentErrorDetail` Payload. fn example() -> Self { Self { - loc: Some(vec!["body".to_owned()]), - msg: Some("Value is not a valid dict.".to_owned()), - err_type: Some("type_error.dict".to_owned()), + loc: Some(vec!["body".into()]), + msg: Some("Value is not a valid dict.".into()), + err_type: Some("type_error.dict".into()), } } } @@ -38,41 +75,8 @@ impl ContentErrorDetail { // TODO: See if we can get more info from the error than this. Self { loc: None, - msg: Some(error.to_string()), + msg: Some(error.to_string().into()), err_type: None, } } } - -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. -pub(crate) struct UnprocessableContent { - #[oai(validator(max_items = "1000", min_items = "1"))] - /// Details of each error in the content that was detected. - /// - /// Note: This may not be ALL errors in the content, as validation of content can stop - /// at any point an error is detected. - detail: Vec, -} - -impl UnprocessableContent { - /// Create a new `ContentErrorDetail` Response Payload. - pub(crate) fn new(errors: Vec) -> Self { - let mut detail = vec![]; - for error in errors { - detail.push(ContentErrorDetail::new(&error)); - } - - Self { detail } - } -} - -impl Example for UnprocessableContent { - /// Example for the Too Many Requests Payload. - fn example() -> Self { - Self { - detail: vec![ContentErrorDetail::example()], - } - } -} diff --git a/catalyst-gateway/bin/src/service/common/responses/code_429_too_many_requests.rs b/catalyst-gateway/bin/src/service/common/responses/code_429_too_many_requests.rs index 195f40a09d2..d3f07429e52 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_429_too_many_requests.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_429_too_many_requests.rs @@ -3,9 +3,9 @@ use poem_openapi::{types::Example, Object}; use uuid::Uuid; -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. +#[derive(Object)] +#[oai(example)] +/// The client has sent too many requests in a given amount of time. pub(crate) struct TooManyRequests { /// Unique ID of this Server Error so that it can be located easily for debugging. id: Uuid, diff --git a/catalyst-gateway/bin/src/service/common/responses/code_500_internal_server_error.rs b/catalyst-gateway/bin/src/service/common/responses/code_500_internal_server_error.rs index d3ad5189c47..f7fc977cd73 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_500_internal_server_error.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_500_internal_server_error.rs @@ -8,9 +8,11 @@ use uuid::Uuid; /// probably want to place this in your crate root use crate::settings::Settings; -#[derive(Debug, Object)] +#[derive(Object)] #[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. +/// An internal server error occurred. +/// +/// *The contents of this response should be reported to the projects issue tracker.* pub(crate) struct InternalServerError { /// Unique ID of this Server Error so that it can be located easily for debugging. id: Uuid, diff --git a/catalyst-gateway/bin/src/service/common/responses/code_503_service_unavailable.rs b/catalyst-gateway/bin/src/service/common/responses/code_503_service_unavailable.rs index 48a840692e0..b5c67daf8b3 100644 --- a/catalyst-gateway/bin/src/service/common/responses/code_503_service_unavailable.rs +++ b/catalyst-gateway/bin/src/service/common/responses/code_503_service_unavailable.rs @@ -3,9 +3,12 @@ use poem_openapi::{types::Example, Object}; use uuid::Uuid; -#[derive(Debug, Object)] -#[oai(example, skip_serializing_if_is_none)] -/// Server Error response to a Bad request. +#[derive(Object)] +#[oai(example)] +/// The service is not available, try again later. +/// +/// *This is returned when the service either has not started, +/// or has become unavailable.* pub(crate) struct ServiceUnavailable { /// Unique ID of this Server Error so that it can be located easily for debugging. id: Uuid, diff --git a/catalyst-gateway/bin/src/service/common/responses/mod.rs b/catalyst-gateway/bin/src/service/common/responses/mod.rs index 31972cb3c3b..7f86a27c52c 100644 --- a/catalyst-gateway/bin/src/service/common/responses/mod.rs +++ b/catalyst-gateway/bin/src/service/common/responses/mod.rs @@ -75,7 +75,7 @@ pub(crate) enum ErrorResponses { /// ## Service Unavailable /// - /// The service is not available, do not send other requests. + /// The service is not available, try again later. /// /// *This is returned when the service either has not started, /// or has become unavailable.* diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/asset_name.rs b/catalyst-gateway/bin/src/service/common/types/cardano/asset_name.rs index 3f8e1f72cf5..0594a0f968a 100644 --- a/catalyst-gateway/bin/src/service/common/types/cardano/asset_name.rs +++ b/catalyst-gateway/bin/src/service/common/types/cardano/asset_name.rs @@ -65,7 +65,6 @@ impl Example for AssetName { } } -// TODO: https://github.com/input-output-hk/catalyst-voices/issues/1121 impl From> for AssetName { fn from(value: Vec) -> Self { match String::from_utf8(value.clone()) { diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/asset_value.rs b/catalyst-gateway/bin/src/service/common/types/cardano/asset_value.rs index fc858682df1..2fcf64a82fb 100644 --- a/catalyst-gateway/bin/src/service/common/types/cardano/asset_value.rs +++ b/catalyst-gateway/bin/src/service/common/types/cardano/asset_value.rs @@ -17,9 +17,11 @@ const DESCRIPTION: &str = "This is a non-zero signed integer."; const EXAMPLE: i128 = 1_234_567; /// Minimum. /// From: +/// This is NOT `i128::MIN`. const MINIMUM: i128 = -9_223_372_036_854_775_808; /// Maximum. /// From: +/// This is NOT `i128::MAX`. const MAXIMUM: i128 = 9_223_372_036_854_775_808; /// Schema. diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs b/catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs new file mode 100644 index 00000000000..a6eac96080a --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/cip19_shelley_address.rs @@ -0,0 +1,144 @@ +//! Cardano address types. +//! +//! More information can be found in [CIP-19](https://cips.cardano.org/cip/CIP-19) + +use std::{ + borrow::Cow, + ops::{Deref, DerefMut}, + sync::LazyLock, +}; + +use const_format::concatcp; +use pallas::ledger::addresses::{Address, ShelleyAddress}; +use poem_openapi::{ + registry::{MetaExternalDocument, MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +use crate::service::common::types::string_types::impl_string_types; + +/// Title +const TITLE: &str = "Cardano Payment Address"; +/// Description +const DESCRIPTION: &str = "Cardano Shelley Payment Address (CIP-19 Formatted)."; +/// Example +// cSpell:disable +const EXAMPLE: &str = "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgs68faae"; +// cSpell:enable +/// Production Address Identifier +const PROD: &str = "addr"; +/// Test Address Identifier +const TEST: &str = "addr_test"; +/// Bech32 Match Pattern +const BECH32: &str = "[a,c-h,j-n,p-z,0,2-9]"; +/// Length of the encoded address (for type 0 - 3). +const ENCODED_STAKED_ADDR_LEN: usize = 98; +/// Length of the encoded address (for type 6 - 7). +const ENCODED_UNSTAKED_ADDR_LEN: usize = 53; +/// Regex Pattern +const PATTERN: &str = concatcp!( + "(", + PROD, + "|", + TEST, + ")1(", + BECH32, + "{", + ENCODED_UNSTAKED_ADDR_LEN, + "}|", + BECH32, + "{", + ENCODED_STAKED_ADDR_LEN, + "})" +); +/// Length of the decoded address. +const DECODED_UNSTAKED_ADDR_LEN: usize = 28; +/// Length of the decoded address. +const DECODED_STAKED_ADDR_LEN: usize = DECODED_UNSTAKED_ADDR_LEN * 2; +/// Minimum length +const MIN_LENGTH: usize = PROD.len() + 1 + ENCODED_UNSTAKED_ADDR_LEN; +/// Minimum length +const MAX_LENGTH: usize = TEST.len() + 1 + ENCODED_STAKED_ADDR_LEN; + +/// External document for Cardano addresses. +static EXTERNAL_DOCS: LazyLock = LazyLock::new(|| { + MetaExternalDocument { + url: "https://cips.cardano.org/cip/CIP-19".to_owned(), + description: Some("CIP-19 - Cardano Addresses".to_owned()), + } +}); + +/// Schema. +static SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(Value::String(EXAMPLE.to_string())), + external_docs: Some(EXTERNAL_DOCS.clone()), + min_length: Some(MIN_LENGTH), + max_length: Some(MAX_LENGTH), + pattern: Some(PATTERN.to_string()), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Because ALL the constraints are defined above, we do not ever need to define them in +/// the API. BUT we do need to make a validator. +/// This helps enforce uniform validation. +fn is_valid(addr: &str) -> bool { + // Just check the string can be safely converted into the type. + if let Ok((hrp, addr)) = bech32::decode(addr) { + let hrp = hrp.as_str(); + (addr.len() == DECODED_UNSTAKED_ADDR_LEN || addr.len() == DECODED_STAKED_ADDR_LEN) + && (hrp == PROD || hrp == TEST) + } else { + false + } +} + +impl_string_types!( + Cip19ShelleyAddress, + "string", + "cardano:cip19-address", + Some(SCHEMA.clone()), + is_valid +); + +impl Cip19ShelleyAddress { + /// Create a new `PaymentAddress`. + #[allow(dead_code)] + pub fn new(address: String) -> Self { + Cip19ShelleyAddress(address) + } +} + +impl TryFrom for Cip19ShelleyAddress { + type Error = anyhow::Error; + + fn try_from(addr: ShelleyAddress) -> Result { + let addr_str = addr + .to_bech32() + .map_err(|e| anyhow::anyhow!(format!("Invalid payment address {e}")))?; + Ok(Self(addr_str)) + } +} + +impl TryInto for Cip19ShelleyAddress { + type Error = anyhow::Error; + + fn try_into(self) -> Result { + let address_str = &self.0; + let address = Address::from_bech32(address_str)?; + match address { + Address::Shelley(address) => Ok(address), + _ => Err(anyhow::anyhow!("Invalid payment address")), + } + } +} + +impl Example for Cip19ShelleyAddress { + fn example() -> Self { + Self(EXAMPLE.to_owned()) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/address.rs b/catalyst-gateway/bin/src/service/common/types/cardano/cip19_stake_address.rs similarity index 52% rename from catalyst-gateway/bin/src/service/common/types/cardano/address.rs rename to catalyst-gateway/bin/src/service/common/types/cardano/cip19_stake_address.rs index 9a3a2e70cc8..83f01951003 100644 --- a/catalyst-gateway/bin/src/service/common/types/cardano/address.rs +++ b/catalyst-gateway/bin/src/service/common/types/cardano/cip19_stake_address.rs @@ -8,6 +8,7 @@ use std::{ sync::LazyLock, }; +use anyhow::bail; use const_format::concatcp; use pallas::ledger::addresses::{Address, StakeAddress}; use poem_openapi::{ @@ -24,14 +25,14 @@ const TITLE: &str = "Cardano stake address"; const DESCRIPTION: &str = "Cardano stake address, also known as a reward address."; /// Stake address example. // cSpell:disable -const EXAMPLE: &str = "stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn"; +pub(crate) const EXAMPLE: &str = "stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn"; // cSpell:enable /// Production Stake Address Identifier const PROD_STAKE: &str = "stake"; /// Test Stake Address Identifier const TEST_STAKE: &str = "stake_test"; /// Regex Pattern -const PATTERN: &str = concatcp!( +pub(crate) const PATTERN: &str = concatcp!( "(", PROD_STAKE, "|", @@ -41,11 +42,14 @@ const PATTERN: &str = concatcp!( /// Length of the encoded address. const ENCODED_ADDR_LEN: usize = 53; /// Length of the decoded address. -const DECODED_ADDR_LEN: usize = 28; +const DECODED_ADDR_LEN: usize = 29; /// Minimum length -const MIN_LENGTH: usize = PROD_STAKE.len() + 1 + ENCODED_ADDR_LEN; +pub(crate) const MIN_LENGTH: usize = PROD_STAKE.len() + 1 + ENCODED_ADDR_LEN; /// Minimum length -const MAX_LENGTH: usize = TEST_STAKE.len() + 1 + ENCODED_ADDR_LEN; +pub(crate) const MAX_LENGTH: usize = TEST_STAKE.len() + 1 + ENCODED_ADDR_LEN; + +/// String Format +pub(crate) const FORMAT: &str = "cardano:cip19-address"; /// External document for Cardano addresses. static EXTERNAL_DOCS: LazyLock = LazyLock::new(|| { @@ -85,31 +89,42 @@ fn is_valid(stake_addr: &str) -> bool { impl_string_types!( Cip19StakeAddress, "string", - "cardano:cip19-address", + FORMAT, Some(STAKE_SCHEMA.clone()), is_valid ); -impl Cip19StakeAddress { - /// Create a new `StakeAddress`. - #[allow(dead_code)] - pub fn new(address: String) -> Self { - Cip19StakeAddress(address) +impl TryFrom<&str> for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + value.to_string().try_into() } +} - /// Convert a `StakeAddress` string to a `StakeAddress`. - pub fn to_stake_address(&self) -> anyhow::Result { - let address_str = &self.0; - let address = Address::from_bech32(address_str)?; - match address { - Address::Stake(stake_address) => Ok(stake_address), - _ => Err(anyhow::anyhow!("Invalid stake address")), - } +impl TryFrom for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_from(value: String) -> Result { + match bech32::decode(&value) { + Ok((hrp, addr)) => { + let hrp = hrp.as_str(); + if addr.len() == DECODED_ADDR_LEN && (hrp == PROD_STAKE || hrp == TEST_STAKE) { + return Ok(Cip19StakeAddress(value)); + } + bail!("Invalid CIP-19 formatted Stake Address") + }, + Err(err) => { + bail!("Invalid CIP-19 formatted Stake Address : {err}"); + }, + }; } +} + +impl TryFrom for Cip19StakeAddress { + type Error = anyhow::Error; - /// Convert a `StakeAddress` to a `StakeAddress` string. - #[allow(dead_code)] - pub fn from_stake_address(addr: &StakeAddress) -> anyhow::Result { + fn try_from(addr: StakeAddress) -> Result { let addr_str = addr .to_bech32() .map_err(|e| anyhow::anyhow!(format!("Invalid stake address {e}")))?; @@ -117,8 +132,52 @@ impl Cip19StakeAddress { } } +impl TryInto for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_into(self) -> Result { + let address_str = &self.0; + let address = Address::from_bech32(address_str)?; + match address { + Address::Stake(address) => Ok(address), + _ => Err(anyhow::anyhow!("Invalid stake address")), + } + } +} + impl Example for Cip19StakeAddress { fn example() -> Self { Self(EXAMPLE.to_owned()) } } + +#[cfg(test)] +mod tests { + use super::*; + + // cspell: disable + const VALID_PROD_STAKE_ADDRESS: &str = + "stake1u94ullc9nj9gawc08990nx8hwgw80l9zpmr8re44kydqy9cdjq6rq"; + const VALID_TEST_STAKE_ADDRESS: &str = + "stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn"; + const INVALID_STAKE_ADDRESS: &str = + "invalid1u9nlq5nmuzthw3vhgakfpxyq4r0zl2c0p8uqy24gpyjsa6c3df4h6"; + // cspell: enable + + #[test] + fn test_valid_stake_address_from_string() { + let stake_address_prod = Cip19StakeAddress::try_from(VALID_PROD_STAKE_ADDRESS.to_string()); + let stake_address_test = Cip19StakeAddress::try_from(VALID_TEST_STAKE_ADDRESS.to_string()); + + assert!(stake_address_prod.is_ok()); + assert!(stake_address_test.is_ok()); + assert_eq!(stake_address_prod.unwrap().0, VALID_PROD_STAKE_ADDRESS); + assert_eq!(stake_address_test.unwrap().0, VALID_TEST_STAKE_ADDRESS); + } + + #[test] + fn test_invalid_stake_address_from_string() { + let stake_address = Cip19StakeAddress::try_from(INVALID_STAKE_ADDRESS.to_string()); + assert!(stake_address.is_err()); + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/hash28.rs b/catalyst-gateway/bin/src/service/common/types/cardano/hash28.rs index 4b3e97cee9a..5c44725d835 100644 --- a/catalyst-gateway/bin/src/service/common/types/cardano/hash28.rs +++ b/catalyst-gateway/bin/src/service/common/types/cardano/hash28.rs @@ -15,7 +15,10 @@ use poem_openapi::{ }; use serde_json::Value; -use crate::service::common::types::string_types::impl_string_types; +use crate::service::{ + common::types::string_types::impl_string_types, + utilities::{as_hex_string, from_hex_string}, +}; /// Title. const TITLE: &str = "28 Byte Hash"; @@ -78,7 +81,7 @@ impl TryFrom> for HexEncodedHash28 { if value.len() != HASH_LENGTH { bail!("Hash Length Invalid.") } - Ok(Self(format!("0x{}", hex::encode(value)))) + Ok(Self(as_hex_string(&value))) } } @@ -87,9 +90,7 @@ impl TryFrom> for HexEncodedHash28 { // All creation of this type should come only from one of the deserialization methods. impl From for Vec { fn from(val: HexEncodedHash28) -> Self { - #[allow(clippy::string_slice)] // 100% safe due to the way this type can be constructed. - let raw_hex = &val.0[2..]; #[allow(clippy::expect_used)] - hex::decode(raw_hex).expect("This can only fail if the type was invalidly constructed.") + from_hex_string(&val.0).expect("This can only fail if the type was invalidly constructed.") } } diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/mod.rs b/catalyst-gateway/bin/src/service/common/types/cardano/mod.rs index d4c4deac6b9..f50e83f268d 100644 --- a/catalyst-gateway/bin/src/service/common/types/cardano/mod.rs +++ b/catalyst-gateway/bin/src/service/common/types/cardano/mod.rs @@ -1,6 +1,11 @@ //! Cardano Types -pub(crate) mod address; pub(crate) mod asset_name; pub(crate) mod asset_value; +pub(crate) mod cip19_shelley_address; +pub(crate) mod cip19_stake_address; pub(crate) mod hash28; +pub(crate) mod nonce; +pub(crate) mod query; +pub(crate) mod slot_no; +pub(crate) mod txn_index; diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/nonce.rs b/catalyst-gateway/bin/src/service/common/types/cardano/nonce.rs new file mode 100644 index 00000000000..e99679b7d7f --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/nonce.rs @@ -0,0 +1,126 @@ +//! Nonce + +use std::sync::LazyLock; + +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +use super::slot_no; + +/// Title. +const TITLE: &str = "Nonce"; +/// Description. +const DESCRIPTION: &str = "The current slot at the time a transaction was posted. +Used to ensure out of order inclusion on-chain can be detected. + +*Note: Because a Nonce should never be greater than the slot of the transaction it is found in, +excessively large nonces are capped to the transactions slot number.*"; +/// Example. +pub(crate) const EXAMPLE: u64 = slot_no::EXAMPLE; +/// Minimum. +const MINIMUM: u64 = 0; +/// Maximum. +const MAXIMUM: u64 = u64::MAX; + +/// Schema. +#[allow(clippy::cast_precision_loss)] +static SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(EXAMPLE.into()), + maximum: Some(MAXIMUM as f64), + minimum: Some(MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Value of a Nonce. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct Nonce(u64); + +/// Is the Nonce valid? +fn is_valid(value: u64) -> bool { + (MINIMUM..=MAXIMUM).contains(&value) +} + +impl Type for Nonce { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u64)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u64"))); + schema_ref.merge(SCHEMA.clone()) + } + + 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 Nonce { + fn parse_from_parameter(value: &str) -> ParseResult { + let nonce: u64 = value.parse()?; + Ok(Self(nonce)) + } +} + +impl ParseFromJSON for Nonce { + fn parse_from_json(value: Option) -> ParseResult { + let value = value.unwrap_or_default(); + match value { + Value::Number(num) => { + let nonce = num + .as_u64() + .ok_or(ParseError::from("nonce must be a positive integer"))?; + if !is_valid(nonce) { + return Err(ParseError::from("nonce out of valid range")); + } + Ok(Self(nonce)) + }, + _ => Err(ParseError::expected_type(value)), + } + } +} + +impl ToJSON for Nonce { + fn to_json(&self) -> Option { + Some(self.0.into()) + } +} + +impl From for Nonce { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl Nonce { + /// Generic conversion of `Option` to `Option`. + #[allow(dead_code)] + pub(crate) fn into_option>(value: Option) -> Option { + value.map(std::convert::Into::into) + } +} + +impl Example for Nonce { + fn example() -> Self { + Self(EXAMPLE) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/query/as_at.rs b/catalyst-gateway/bin/src/service/common/types/cardano/query/as_at.rs new file mode 100644 index 00000000000..f95fbb607f6 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/query/as_at.rs @@ -0,0 +1,169 @@ +//! Query Parameter that can take either a Blockchain slot Number of Unix Epoch timestamp. +//! +//! Allows better specifying of times that restrict a GET endpoints response. + +//! Hex encoded 28 byte hash. +//! +//! Hex encoded string which represents a 28 byte hash. + +use std::{ + cmp::{max, min}, + fmt::{self, Display}, + sync::LazyLock, +}; + +use anyhow::{bail, Result}; +use chrono::DateTime; +use const_format::concatcp; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{ParseError, ParseFromParameter, ParseResult, Type}, +}; +use regex::Regex; +use serde_json::Value; + +use crate::{service::common::types::cardano::slot_no::SlotNo, settings::Settings}; + +/// Title. +const TITLE: &str = "As At this Time OR Slot."; +/// Description. +const DESCRIPTION: &str = "Restrict the query to this time. +Time can be represented as either the blockchains slot number, +or the number of seconds since midnight 1970, UTC. + +If this parameter is not defined, the query will retrieve data up to the current time."; +/// Example whence. +const EXAMPLE_WHENCE: &str = TIME_DISCRIMINATOR; +/// Example time. +const EXAMPLE_TIME: u64 = 1_730_861_339; // Date and time (UTC): November 6, 2024 2:48:59 AM +/// Example +static EXAMPLE: LazyLock = LazyLock::new(|| { + // Note, the SlotNumber here is wrong, but its not used for generating the example, so + // thats OK. + let example = AsAt((EXAMPLE_WHENCE.to_owned(), EXAMPLE_TIME, 0.into())); + format!("{example}") +}); +/// Time Discriminator +const TIME_DISCRIMINATOR: &str = "TIME"; +/// Slot Discriminator +const SLOT_DISCRIMINATOR: &str = "SLOT"; +/// Validation Regex Pattern +const PATTERN: &str = concatcp!( + "^(", + SLOT_DISCRIMINATOR, + "|", + TIME_DISCRIMINATOR, + r"):(\d{1,20})$" +); +/// Minimum parameter length +static MIN_LENGTH: LazyLock = + LazyLock::new(|| min(TIME_DISCRIMINATOR.len(), SLOT_DISCRIMINATOR.len()) + ":0".len()); +/// Maximum parameter length +static MAX_LENGTH: LazyLock = LazyLock::new(|| { + max(TIME_DISCRIMINATOR.len(), SLOT_DISCRIMINATOR.len()) + ":".len() + u64::MAX.to_string().len() +}); + +/// Parse the `AsAt` parameter from the Query string provided. +fn parse_parameter(param: &str) -> Result<(String, u64)> { + /// Regex to parse the parameter + #[allow(clippy::unwrap_used)] // Safe because the Regex is constant. Can never panic in prod. + static RE: LazyLock = LazyLock::new(|| Regex::new(PATTERN).unwrap()); + + let Some(results) = RE.captures(param) else { + bail!("Not a valid `as_at` parameter."); + }; + let whence = &results[1]; + let Ok(when) = results[2].parse::() else { + bail!( + "Not a valid `as_at` parameter. Invalid {} specified.", + whence + ); + }; + Ok((whence.to_owned(), when)) +} + +/// Schema. +static SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(Value::String(EXAMPLE.to_string())), + max_length: Some(*MAX_LENGTH), + min_length: Some(*MIN_LENGTH), + pattern: Some(PATTERN.to_string()), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// As at time from query string parameter. +/// Store (Whence, When and decoded `SlotNo`) in a tuple for easier access. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct AsAt((String, u64, SlotNo)); + +impl Type for AsAt { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "string(slot or time)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format( + "string", + "slot or time", + ))); + schema_ref.merge(SCHEMA.clone()) + } + + 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 AsAt { + fn parse_from_parameter(value: &str) -> ParseResult { + let (whence, when) = parse_parameter(value)?; + let slot = if whence == TIME_DISCRIMINATOR { + let network = Settings::cardano_network(); + let Ok(epoch_time) = when.try_into() else { + return Err(ParseError::from(format!( + "time {when} too far in the future" + ))); + }; + let Some(datetime) = DateTime::from_timestamp(epoch_time, 0) else { + return Err(ParseError::from(format!("invalid time {when}"))); + }; + let Some(slot) = network.time_to_slot(datetime) else { + return Err(ParseError::from(format!( + "invalid time {when} for network: {network}" + ))); + }; + slot + } else { + when + }; + let slot_no: SlotNo = slot.into(); + Ok(Self((whence, when, slot_no))) + } +} + +impl From for SlotNo { + fn from(value: AsAt) -> Self { + value.0 .2 + } +} + +impl Display for AsAt { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.0 .0, self.0 .1) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/query/mod.rs b/catalyst-gateway/bin/src/service/common/types/cardano/query/mod.rs new file mode 100644 index 00000000000..2583fded07c --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/query/mod.rs @@ -0,0 +1,9 @@ +//! These types are specifically and only used for Query Parameters +//! +//! They exist due to limitations in the expressiveness of Query parameter constraints in +//! `OpenAPI` + +pub(crate) mod as_at; +pub(crate) mod stake_or_voter; + +pub(crate) use as_at::AsAt; diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/query/stake_or_voter.rs b/catalyst-gateway/bin/src/service/common/types/cardano/query/stake_or_voter.rs new file mode 100644 index 00000000000..63e2aaf7a84 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/query/stake_or_voter.rs @@ -0,0 +1,200 @@ +//! Query Parameter that can take a CIP-19 stake address, or a hex encoded vote public +//! key. +//! +//! Allows us to have one parameter that can represent two things, uniformly. + +use std::{ + cmp::{max, min}, + sync::LazyLock, +}; + +use anyhow::{bail, Result}; +use const_format::concatcp; +use poem::http::HeaderMap; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{ParseFromParameter, ParseResult, Type}, +}; +use regex::Regex; +use serde_json::Value; + +use crate::service::common::{self, auth::api_key::check_api_key}; + +/// A Query Parameter that can take a CIP-19 stake address, or a public key. +/// Defining these are mutually exclusive, ao a single parameter is required to be used. +#[derive(Clone)] +pub(crate) enum StakeAddressOrPublicKey { + /// A CIP-19 stake address + Address(common::types::cardano::cip19_stake_address::Cip19StakeAddress), + /// A Ed25519 Public Key + PublicKey(common::types::generic::ed25519_public_key::Ed25519HexEncodedPublicKey), + /// Special value that means we try to fetch all possible results. Must be protected + /// with an `APIKey`. + All, +} + +impl From for StakeAddressOrPublicKey { + fn from(value: StakeOrVoter) -> Self { + value.0 .1 + } +} + +impl TryFrom<&str> for StakeAddressOrPublicKey { + type Error = anyhow::Error; + + fn try_from(value: &str) -> std::result::Result { + /// Regex to parse the parameter + #[allow(clippy::unwrap_used)] // Safe because the Regex is constant. Can never panic in prod. + static RE: LazyLock = LazyLock::new(|| Regex::new(PATTERN).unwrap()); + + // First check it is the special "ALL" parameter. + if value == "ALL" { + return Ok(Self::All); + } + + // Otherwise, work out use the regex to work out what it is, and validate it. + if let Some(results) = RE.captures(value) { + if let Some(stake_addr) = results.get(1) { + return Ok(Self::Address(stake_addr.as_str().try_into()?)); + } else if let Some(public_key) = results.get(2) { + return Ok(Self::PublicKey(public_key.as_str().try_into()?)); + } + } + bail!("Not a valid \"Stake or Public Key\" parameter."); + } +} + +/// Title. +const TITLE: &str = "Stake Address or Voting Key."; +/// Description. +const DESCRIPTION: &str = "Restrict the query to this Stake address, or Voters Public Key. +If neither are defined, the stake address(es) from the auth tokens role0 registration are used."; +/// Example +const EXAMPLE: &str = common::types::cardano::cip19_stake_address::EXAMPLE; +/// Stake Address Pattern +const STAKE_PATTERN: &str = common::types::cardano::cip19_stake_address::PATTERN; +/// Voting Key Pattern +const VOTING_KEY_PATTERN: &str = common::types::generic::ed25519_public_key::PATTERN; +/// Validation Regex Pattern +const PATTERN: &str = concatcp!("^(", STAKE_PATTERN, ")|(", VOTING_KEY_PATTERN, ")$"); +/// Minimum parameter length +static MIN_LENGTH: LazyLock = LazyLock::new(|| { + min( + common::types::cardano::cip19_stake_address::MIN_LENGTH, + common::types::generic::ed25519_public_key::ENCODED_LENGTH, + ) +}); +/// Maximum parameter length +static MAX_LENGTH: LazyLock = LazyLock::new(|| { + max( + common::types::cardano::cip19_stake_address::MAX_LENGTH, + common::types::generic::ed25519_public_key::ENCODED_LENGTH, + ) +}); + +/// Format +const FORMAT: &str = concatcp!( + common::types::cardano::cip19_stake_address::FORMAT, + "|", + common::types::generic::ed25519_public_key::FORMAT +); + +/// Schema. +static SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(Value::String(EXAMPLE.to_string())), + max_length: Some(*MAX_LENGTH), + min_length: Some(*MIN_LENGTH), + pattern: Some(PATTERN.to_string()), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Either a Stake Address or a ED25519 Public key. +#[derive(Clone)] +pub(crate) struct StakeOrVoter((String, StakeAddressOrPublicKey)); + +impl TryFrom<&str> for StakeOrVoter { + type Error = anyhow::Error; + + fn try_from(value: &str) -> std::result::Result { + Ok(Self((value.to_string(), value.try_into()?))) + } +} + +impl Type for StakeOrVoter { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + format!("string({FORMAT})").into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("string", FORMAT))); + schema_ref.merge(SCHEMA.clone()) + } + + 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 StakeOrVoter { + fn parse_from_parameter(value: &str) -> ParseResult { + Ok(Self((value.to_string(), value.try_into()?))) + } +} + +impl StakeOrVoter { + /// Is this for ALL results? + pub(crate) fn is_all(&self, headers: &HeaderMap) -> Result { + match self.0 .1 { + StakeAddressOrPublicKey::All => { + check_api_key(headers)?; + Ok(true) + }, + _ => Ok(false), + } + } +} + +impl TryInto for StakeOrVoter { + type Error = anyhow::Error; + + fn try_into( + self, + ) -> Result { + match self.0 .1 { + StakeAddressOrPublicKey::Address(addr) => Ok(addr), + _ => bail!("Not a Stake Address"), + } + } +} + +impl TryInto + for StakeOrVoter +{ + type Error = anyhow::Error; + + fn try_into( + self, + ) -> Result + { + match self.0 .1 { + StakeAddressOrPublicKey::PublicKey(key) => Ok(key), + _ => bail!("Not a Stake Address"), + } + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/slot_no.rs b/catalyst-gateway/bin/src/service/common/types/cardano/slot_no.rs new file mode 100644 index 00000000000..f2e2f2d2daa --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/slot_no.rs @@ -0,0 +1,131 @@ +//! Slot Number on the blockchain. + +use std::sync::LazyLock; + +use anyhow::bail; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +/// Title. +const TITLE: &str = "Cardano Blockchain Slot Number"; +/// Description. +const DESCRIPTION: &str = "The Slot Number of a Cardano Block on the chain."; +/// Example. +pub(crate) const EXAMPLE: u64 = 1_234_567; +/// Minimum. +const MINIMUM: u64 = 0; +/// Maximum. +const MAXIMUM: u64 = u64::MAX; + +/// Schema. +#[allow(clippy::cast_precision_loss)] +static SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(EXAMPLE.into()), + maximum: Some(MAXIMUM as f64), + minimum: Some(MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Slot number +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct SlotNo(u64); + +/// Is the Slot Number valid? +fn is_valid(_value: u64) -> bool { + true +} + +impl Type for SlotNo { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u64)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u64"))); + schema_ref.merge(SCHEMA.clone()) + } + + 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 SlotNo { + fn parse_from_parameter(value: &str) -> ParseResult { + let slot: u64 = value.parse()?; + Ok(Self(slot)) + } +} + +impl ParseFromJSON for SlotNo { + fn parse_from_json(value: Option) -> ParseResult { + let value = value.unwrap_or_default(); + if let Value::Number(value) = value { + let value = value + .as_u64() + .ok_or(ParseError::from("invalid slot number"))?; + if !is_valid(value) { + return Err("invalid AssetValue".into()); + } + Ok(Self(value)) + } else { + Err(ParseError::expected_type(value)) + } + } +} + +impl ToJSON for SlotNo { + fn to_json(&self) -> Option { + Some(self.0.into()) + } +} + +impl TryFrom for SlotNo { + type Error = anyhow::Error; + + fn try_from(value: i64) -> Result { + let value: u64 = value.try_into()?; + if !is_valid(value) { + bail!("Invalid Slot Number"); + } + Ok(Self(value)) + } +} + +impl From for SlotNo { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl SlotNo { + /// Generic conversion of `Option` to `Option`. + pub(crate) fn into_option>(value: Option) -> Option { + value.map(std::convert::Into::into) + } +} + +impl Example for SlotNo { + fn example() -> Self { + Self(EXAMPLE) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/cardano/txn_index.rs b/catalyst-gateway/bin/src/service/common/types/cardano/txn_index.rs new file mode 100644 index 00000000000..7a48bd1c344 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/cardano/txn_index.rs @@ -0,0 +1,147 @@ +//! Transaction Index within a block. + +use std::sync::LazyLock; + +use anyhow::bail; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +/// Title. +const TITLE: &str = "Transaction Index"; +/// Description. +const DESCRIPTION: &str = "The Index of a transaction within a block."; +/// Example. +const EXAMPLE: u16 = 7; +/// Minimum. +const MINIMUM: u16 = 0; +/// Maximum. +const MAXIMUM: u16 = u16::MAX; +/// Invalid Error Msg. +const INVALID_MSG: &str = "Invalid Transaction Index."; + +/// Schema. +#[allow(clippy::cast_lossless)] +static SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(EXAMPLE.into()), + maximum: Some(MAXIMUM as f64), + minimum: Some(MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Transaction Index within a block. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct TxnIndex(u16); + +/// Is the Slot Number valid? +fn is_valid(_value: u16) -> bool { + true +} + +impl Type for TxnIndex { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u16)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u16"))); + schema_ref.merge(SCHEMA.clone()) + } + + 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 TxnIndex { + fn parse_from_parameter(value: &str) -> ParseResult { + let idx: u16 = value.parse()?; + Ok(Self(idx)) + } +} + +impl ParseFromJSON for TxnIndex { + fn parse_from_json(value: Option) -> ParseResult { + let value = value.unwrap_or_default(); + if let Value::Number(value) = value { + let value = value + .as_u64() + .ok_or(ParseError::from(INVALID_MSG))? + .try_into()?; + if !is_valid(value) { + return Err(INVALID_MSG.into()); + } + Ok(Self(value)) + } else { + Err(ParseError::expected_type(value)) + } + } +} + +impl ToJSON for TxnIndex { + fn to_json(&self) -> Option { + Some(self.0.into()) + } +} + +impl TryFrom for TxnIndex { + type Error = anyhow::Error; + + fn try_from(value: u64) -> Result { + let value: u16 = value.try_into()?; + if !is_valid(value) { + bail!(INVALID_MSG); + } + Ok(Self(value)) + } +} + +impl TryFrom for TxnIndex { + type Error = anyhow::Error; + + fn try_from(value: i16) -> Result { + let value: u16 = value.try_into()?; + if !is_valid(value) { + bail!(INVALID_MSG); + } + Ok(Self(value)) + } +} + +impl From for TxnIndex { + fn from(value: u16) -> Self { + Self(value) + } +} + +impl TxnIndex { + /// Generic conversion of `Option` to `Option`. + #[allow(dead_code)] + pub(crate) fn into_option>(value: Option) -> Option { + value.map(std::convert::Into::into) + } +} + +impl Example for TxnIndex { + fn example() -> Self { + Self(EXAMPLE) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/generic/ed25519_public_key.rs b/catalyst-gateway/bin/src/service/common/types/generic/ed25519_public_key.rs index 92fbd2c572d..eb0954f4e61 100644 --- a/catalyst-gateway/bin/src/service/common/types/generic/ed25519_public_key.rs +++ b/catalyst-gateway/bin/src/service/common/types/generic/ed25519_public_key.rs @@ -8,24 +8,30 @@ use std::{ sync::LazyLock, }; +use anyhow::bail; use poem_openapi::{ registry::{MetaSchema, MetaSchemaRef}, types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, }; use serde_json::Value; -use crate::{service::common::types::string_types::impl_string_types, utils::ed25519}; +use crate::{ + service::{common::types::string_types::impl_string_types, utilities::as_hex_string}, + utils::ed25519, +}; /// Title. const TITLE: &str = "Ed25519 Public Key"; /// Description. const DESCRIPTION: &str = "This is a 32 Byte Hex encoded Ed25519 Public Key."; /// Example. -const EXAMPLE: &str = "0x98dbd3d884068eee77e5894c22268d5d12e6484ba713e7ddd595abba308d88d3"; +const EXAMPLE: &str = "0x56CDD154355E078A0990F9E633F9553F7D43A68B2FF9BEF78B9F5C71C808A7C8"; /// Length of the hex encoded string -const ENCODED_LENGTH: usize = ed25519::HEX_ENCODED_LENGTH; +pub(crate) const ENCODED_LENGTH: usize = ed25519::HEX_ENCODED_LENGTH; /// Validation Regex Pattern -const PATTERN: &str = "0x[A-Fa-f0-9]{64}"; +pub(crate) const PATTERN: &str = "0x[A-Fa-f0-9]{64}"; +/// Format +pub(crate) const FORMAT: &str = "hex:ed25519-public-key"; /// Schema static SCHEMA: LazyLock = LazyLock::new(|| { @@ -52,7 +58,7 @@ fn is_valid(hex_key: &str) -> bool { impl_string_types!( Ed25519HexEncodedPublicKey, "string", - "hex:ed25519-public-key", + FORMAT, Some(SCHEMA.clone()), is_valid ); @@ -64,13 +70,55 @@ impl Example for Ed25519HexEncodedPublicKey { } } +impl Ed25519HexEncodedPublicKey { + /// Extra examples of 32 bytes ED25519 Public Key. + pub(crate) fn examples(index: usize) -> Self { + match index { + 0 => { + Self( + "0xDEF855AE45F3BF9640A5298A38B97617DE75600F796F17579BFB815543624B24".to_owned(), + ) + }, + 1 => { + Self( + "0x83B3B55589797EF953E24F4F0DBEE4D50B6363BCF041D15F6DBD33E014E54711".to_owned(), + ) + }, + _ => { + Self( + "0xA3E52361AFDE840918E2589DBAB9967C8027FB4431E83D36E338748CD6E3F820".to_owned(), + ) + }, + } + } +} + +impl TryFrom<&str> for Ed25519HexEncodedPublicKey { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + value.to_string().try_into() + } +} + +impl TryFrom for Ed25519HexEncodedPublicKey { + type Error = anyhow::Error; + + fn try_from(value: String) -> Result { + if !is_valid(&value) { + bail!("Invalid Ed25519 Public key") + } + Ok(Self(value)) + } +} + impl TryFrom> for Ed25519HexEncodedPublicKey { type Error = anyhow::Error; fn try_from(value: Vec) -> Result { let key = ed25519::verifying_key_from_vec(&value)?; - Ok(Self(format!("0x{}", hex::encode(key)))) + Ok(Self(as_hex_string(key.as_ref()))) } } diff --git a/catalyst-gateway/bin/src/service/common/types/generic/error_msg.rs b/catalyst-gateway/bin/src/service/common/types/generic/error_msg.rs new file mode 100644 index 00000000000..f43e82179f2 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/generic/error_msg.rs @@ -0,0 +1,79 @@ +//! Generic Error Messages + +use std::{ + borrow::Cow, + ops::{Deref, DerefMut}, + sync::LazyLock, +}; + +use const_format::concatcp; +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use regex::Regex; +use serde_json::Value; + +use crate::service::common::types::string_types::impl_string_types; + +/// Title. +const TITLE: &str = "Error Message"; +/// Description. +const DESCRIPTION: &str = "This is an error message."; +/// Example. +const EXAMPLE: &str = "An error has occurred, the details of the error are ..."; +/// Max Length +const MAX_LENGTH: usize = 256; +/// Min Length +const MIN_LENGTH: usize = 1; +/// Validation Regex Pattern +const PATTERN: &str = concatcp!("^(.){", MIN_LENGTH, ",", MAX_LENGTH, "}$"); + +/// Schema +static SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(TITLE.to_owned()), + description: Some(DESCRIPTION), + example: Some(Value::String(EXAMPLE.to_string())), + max_length: Some(MAX_LENGTH), + min_length: Some(MIN_LENGTH), + pattern: Some(PATTERN.to_string()), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Check if we match the regex. +fn is_valid(msg: &str) -> bool { + /// Validation pattern + #[allow(clippy::unwrap_used)] // Safe because the Regex is constant. Can never panic in prod. + static RE: LazyLock = LazyLock::new(|| Regex::new(PATTERN).unwrap()); + + RE.is_match(msg) +} + +impl_string_types!( + ErrorMessage, + "string", + "error", + Some(SCHEMA.clone()), + is_valid +); + +impl Example for ErrorMessage { + /// An example 32 bytes ED25519 Public Key. + fn example() -> Self { + Self(EXAMPLE.to_owned()) + } +} + +impl From for ErrorMessage { + fn from(val: String) -> Self { + Self(val) + } +} + +impl From<&str> for ErrorMessage { + fn from(val: &str) -> Self { + Self(val.to_owned()) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/generic/mod.rs b/catalyst-gateway/bin/src/service/common/types/generic/mod.rs index 27d8845f262..a04a6ed0a59 100644 --- a/catalyst-gateway/bin/src/service/common/types/generic/mod.rs +++ b/catalyst-gateway/bin/src/service/common/types/generic/mod.rs @@ -3,3 +3,5 @@ //! These types may be used in Cardano, but are not specific to Cardano. pub(crate) mod ed25519_public_key; +pub(crate) mod error_msg; +pub(crate) mod query; diff --git a/catalyst-gateway/bin/src/service/common/types/generic/query/mod.rs b/catalyst-gateway/bin/src/service/common/types/generic/query/mod.rs new file mode 100644 index 00000000000..bc560e0ba21 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/generic/query/mod.rs @@ -0,0 +1,12 @@ +//! Generic Query ONLY parameters. + +pub(crate) mod pagination; + +// To add pagination to an endpoint add these two lines to the parameters: +// +// ``` +// #[doc = common::types::generic::query::pagination::PAGE_DESCRIPTION] +// page: Query>, +// #[doc = common::types::generic::query::pagination::LIMIT_DESCRIPTION] +// limit: Query> +// ``` diff --git a/catalyst-gateway/bin/src/service/common/types/generic/query/pagination.rs b/catalyst-gateway/bin/src/service/common/types/generic/query/pagination.rs new file mode 100644 index 00000000000..85824bd4a74 --- /dev/null +++ b/catalyst-gateway/bin/src/service/common/types/generic/query/pagination.rs @@ -0,0 +1,360 @@ +//! Consistent Pagination Types +//! +//! These types are paired and must be used together. +//! +//! Page - The Page we wish to request, defaults to 0. +//! Limit - The Limit we wish to request, defaults to 100. + +use std::sync::LazyLock; + +use poem_openapi::{ + registry::{MetaSchema, MetaSchemaRef}, + types::{Example, ParseError, ParseFromJSON, ParseFromParameter, ParseResult, ToJSON, Type}, +}; +use serde_json::Value; + +//***** PAGE */ +/// Page Title. +const PAGE_TITLE: &str = "Page"; +/// Description. +macro_rules! page_description { + () => { + "The page number of the data. +The size of each page, and its offset within the complete data set is determined by the `limit` parameter." + }; +} +pub(crate) use page_description; +/// Description +pub(crate) const PAGE_DESCRIPTION: &str = page_description!(); +/// Example. +const PAGE_EXAMPLE: u64 = 5; +/// Default +const PAGE_DEFAULT: u64 = 0; +/// Page Minimum. +const PAGE_MINIMUM: u64 = 0; +/// Page Maximum. +const PAGE_MAXIMUM: u64 = u64::MAX; + +/// Schema. +#[allow(clippy::cast_precision_loss)] +static PAGE_SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(PAGE_TITLE.to_owned()), + description: Some(PAGE_DESCRIPTION), + example: Some(PAGE_EXAMPLE.into()), + default: Page(PAGE_DEFAULT).to_json(), + maximum: Some(PAGE_MAXIMUM as f64), + minimum: Some(PAGE_MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Page to be returned in the response. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct Page(u64); + +impl Default for Page { + fn default() -> Self { + Self(PAGE_DEFAULT) + } +} + +/// Is the `Page` valid? +fn is_valid_page(value: u64) -> bool { + (PAGE_MINIMUM..=PAGE_MAXIMUM).contains(&value) +} + +impl Type for Page { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u64)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u64"))); + schema_ref.merge(PAGE_SCHEMA.clone()) + } + + 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 Page { + fn parse_from_parameter(value: &str) -> ParseResult { + let page: u64 = value.parse()?; + Ok(Page(page)) + } +} + +impl ParseFromJSON for Page { + fn parse_from_json(value: Option) -> ParseResult { + let value = value.unwrap_or_default(); + if let Value::Number(value) = value { + let value = value.as_u64().unwrap_or_default(); + if !is_valid_page(value) { + return Err("invalid Page".into()); + } + Ok(Self(value)) + } else { + Err(ParseError::expected_type(value)) + } + } +} + +impl ToJSON for Page { + fn to_json(&self) -> Option { + Some(self.0.into()) + } +} + +impl From for Page { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl Example for Page { + fn example() -> Self { + Self(PAGE_EXAMPLE) + } +} + +//***** LIMIT */ +/// Title. +const LIMIT_TITLE: &str = "Limit"; +/// Description - must be suitable for both the Query and Response docs. +macro_rules! limit_description { + () => { + "The size `limit` of each `page` of results. +Determines the maximum amount of data that can be returned in a valid response. + +This `limit` of records of data will always be returned unless there is less data to return +than allowed for by the `limit` and `page`. + +*Exceeding the `page`/`limit` of all available records will not return `404`, it will return an +empty response.*" + }; +} +pub(crate) use limit_description; +/// Description +pub(crate) const LIMIT_DESCRIPTION: &str = limit_description!(); +/// Example. +const LIMIT_EXAMPLE: u64 = 10; +/// Default Limit (Should be used by paged responses to set the maximum size of the +/// response). +pub(crate) const LIMIT_DEFAULT: u64 = 100; +/// Minimum. +const LIMIT_MINIMUM: u64 = 1; +/// Maximum. +const LIMIT_MAXIMUM: u64 = LIMIT_DEFAULT; + +/// Schema. +#[allow(clippy::cast_precision_loss)] +static LIMIT_SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(LIMIT_TITLE.to_owned()), + description: Some(LIMIT_DESCRIPTION), + example: Some(LIMIT_EXAMPLE.into()), + default: Page(LIMIT_DEFAULT).to_json(), + maximum: Some(LIMIT_MAXIMUM as f64), + minimum: Some(LIMIT_MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Limit of items to be returned in a page of data. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct Limit(u64); + +impl Default for Limit { + fn default() -> Self { + Self(LIMIT_DEFAULT) + } +} + +/// Is the `Page` valid? +fn is_valid_limit(value: u64) -> bool { + (LIMIT_MINIMUM..=LIMIT_MAXIMUM).contains(&value) +} + +impl Type for Limit { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u64)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u64"))); + schema_ref.merge(LIMIT_SCHEMA.clone()) + } + + 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 Limit { + fn parse_from_parameter(value: &str) -> ParseResult { + let limit: u64 = value.parse()?; + Ok(Limit(limit)) + } +} + +impl ParseFromJSON for Limit { + fn parse_from_json(value: Option) -> ParseResult { + let value = value.unwrap_or_default(); + if let Value::Number(value) = value { + let value = value.as_u64().unwrap_or_default(); + if !is_valid_limit(value) { + return Err("invalid Limit".into()); + } + Ok(Self(value)) + } else { + Err(ParseError::expected_type(value)) + } + } +} + +impl ToJSON for Limit { + fn to_json(&self) -> Option { + Some(self.0.into()) + } +} + +impl From for Limit { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl Example for Limit { + fn example() -> Self { + Self(LIMIT_EXAMPLE) + } +} + +//***** REMAINING : Not a Query Parameter, but tightly coupled type used in the pagination +//***** response. */ +/// Title. +const REMAINING_TITLE: &str = "Remaining"; +/// Description. +macro_rules! remaining_description { + () => { + "The number of items remaining to be returned after this page. +This is the absolute number of items remaining, and not the number of Pages." + }; +} +pub(crate) use remaining_description; +/// Description +pub(crate) const REMAINING_DESCRIPTION: &str = remaining_description!(); +/// Example. +const REMAINING_EXAMPLE: u64 = 16_384; +/// Minimum. +const REMAINING_MINIMUM: u64 = 0; +/// Maximum. +const REMAINING_MAXIMUM: u64 = u64::MAX; + +/// Schema. +#[allow(clippy::cast_precision_loss)] +static REMAINING_SCHEMA: LazyLock = LazyLock::new(|| { + MetaSchema { + title: Some(REMAINING_TITLE.to_owned()), + description: Some(REMAINING_DESCRIPTION), + example: Some(REMAINING_EXAMPLE.into()), + maximum: Some(REMAINING_MAXIMUM as f64), + minimum: Some(REMAINING_MINIMUM as f64), + ..poem_openapi::registry::MetaSchema::ANY + } +}); + +/// Limit of items to be returned in a page of data. +#[derive(Debug, Eq, PartialEq, Hash)] +pub(crate) struct Remaining(u64); + +/// Is the `Page` valid? +fn is_valid_remaining(value: u64) -> bool { + (REMAINING_MINIMUM..=REMAINING_MAXIMUM).contains(&value) +} + +impl Type for Remaining { + type RawElementValueType = Self; + type RawValueType = Self; + + const IS_REQUIRED: bool = true; + + fn name() -> std::borrow::Cow<'static, str> { + "integer(u64)".into() + } + + fn schema_ref() -> MetaSchemaRef { + let schema_ref = + MetaSchemaRef::Inline(Box::new(MetaSchema::new_with_format("integer", "u64"))); + schema_ref.merge(REMAINING_SCHEMA.clone()) + } + + 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 ParseFromJSON for Remaining { + fn parse_from_json(value: Option) -> ParseResult { + let value = value.unwrap_or_default(); + if let Value::Number(value) = value { + let value = value.as_u64().unwrap_or_default(); + if !is_valid_remaining(value) { + return Err("invalid Remaining".into()); + } + Ok(Self(value)) + } else { + Err(ParseError::expected_type(value)) + } + } +} + +impl ToJSON for Remaining { + fn to_json(&self) -> Option { + Some(self.0.into()) + } +} + +impl From for Remaining { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl Example for Remaining { + fn example() -> Self { + Self(REMAINING_EXAMPLE) + } +} diff --git a/catalyst-gateway/bin/src/service/common/types/string_types.rs b/catalyst-gateway/bin/src/service/common/types/string_types.rs index 8f65629697d..5655fe9dd83 100644 --- a/catalyst-gateway/bin/src/service/common/types/string_types.rs +++ b/catalyst-gateway/bin/src/service/common/types/string_types.rs @@ -32,11 +32,11 @@ /// impl for MyNewType { ... } /// ``` macro_rules! impl_string_types { - ($(#[$docs:meta])* $ty:ident, $type_name:literal, $format:literal, $schema:expr ) => { + ($(#[$docs:meta])* $ty:ident, $type_name:literal, $format:expr, $schema:expr ) => { impl_string_types!($(#[$docs])* $ty, $type_name, $format, $schema, |_| true); }; - ($(#[$docs:meta])* $ty:ident, $type_name:literal, $format:literal, $schema:expr, $validator:expr) => { + ($(#[$docs:meta])* $ty:ident, $type_name:literal, $format:expr, $schema:expr, $validator:expr) => { $(#[$docs])* #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub(crate) struct $ty(String); @@ -69,7 +69,7 @@ macro_rules! impl_string_types { type RawElementValueType = Self; fn name() -> Cow<'static, str> { - concat!($type_name, "(", $format, ")").into() + format!("{}({})", $type_name, $format).into() } fn schema_ref() -> MetaSchemaRef { @@ -103,7 +103,7 @@ macro_rules! impl_string_types { if let Value::String(value) = value { let validator = $validator; if !validator(&value) { - return Err(concat!("invalid ", $format).into()); + return Err(format!("invalid {}", $format).into()); } Ok(Self(value)) } else { @@ -116,7 +116,7 @@ macro_rules! impl_string_types { fn parse_from_parameter(value: &str) -> ParseResult { let validator = $validator; if !validator(value) { - return Err(concat!("invalid ", $format).into()); + return Err(format!("invalid {}", $format).into()); } Ok(Self(value.to_string())) } diff --git a/catalyst-gateway/bin/src/service/utilities/mod.rs b/catalyst-gateway/bin/src/service/utilities/mod.rs index 796aca69224..94990a2ec2f 100644 --- a/catalyst-gateway/bin/src/service/utilities/mod.rs +++ b/catalyst-gateway/bin/src/service/utilities/mod.rs @@ -4,16 +4,30 @@ pub(crate) mod convert; pub(crate) mod middleware; pub(crate) mod net; -use pallas::ledger::addresses::Network as PallasNetwork; -use poem_openapi::types::ToJSON; +use anyhow::{bail, Result}; +// use pallas::ledger::addresses::Network as PallasNetwork; +// use poem_openapi::types::ToJSON; -use crate::service::common::objects::cardano::network::Network; +// use crate::service::common::objects::cardano::network::Network; /// Convert bytes to hex string with the `0x` prefix -pub(crate) fn to_hex_with_prefix(bytes: &[u8]) -> String { +pub(crate) fn as_hex_string(bytes: &[u8]) -> String { format!("0x{}", hex::encode(bytes)) } +/// Convert bytes to hex string with the `0x` prefix +pub(crate) fn from_hex_string(hex: &str) -> Result> { + #[allow(clippy::string_slice)] // Safe because of size checks. + if hex.len() < 4 || hex.len() % 2 != 0 || &hex[0..2] != "0x" { + bail!("Invalid hex string"); + } + + #[allow(clippy::string_slice)] // Safe due to above checks. + Ok(hex::decode(&hex[2..])?) +} + +/// Unused +const _UNUSED: &str = r#" /// Network validation error #[derive(thiserror::Error, Debug)] pub(crate) enum NetworkValidationError { @@ -63,3 +77,4 @@ pub(crate) fn check_network( PallasNetwork::Other(x) => Err(NetworkValidationError::UnknownNetwork(x).into()), } } +"#; diff --git a/catalyst-gateway/bin/src/settings/mod.rs b/catalyst-gateway/bin/src/settings/mod.rs index 2021501d8d8..28726514680 100644 --- a/catalyst-gateway/bin/src/settings/mod.rs +++ b/catalyst-gateway/bin/src/settings/mod.rs @@ -8,6 +8,7 @@ use std::{ }; use anyhow::anyhow; +use cardano_chain_follower::Network; use clap::Args; use dotenvy::dotenv; use duration_string::DurationString; @@ -270,6 +271,12 @@ impl Settings { ENV_VARS.chain_follower.clone() } + /// Chain Follower network (The Blockchain network we are configured to use). + /// Note: Catalyst Gateway can ONLY follow one network at a time. + pub(crate) fn cardano_network() -> Network { + ENV_VARS.chain_follower.chain + } + /// The API Url prefix pub(crate) fn api_url_prefix() -> &'static str { ENV_VARS.api_url_prefix.as_str() diff --git a/catalyst-gateway/rustfmt.toml b/catalyst-gateway/rustfmt.toml index fa6d8c2e906..905bde2d0bd 100644 --- a/catalyst-gateway/rustfmt.toml +++ b/catalyst-gateway/rustfmt.toml @@ -65,4 +65,4 @@ condense_wildcard_suffixes = true hex_literal_case = "Upper" # Ignored files: -ignore = [] \ No newline at end of file +ignore = [] diff --git a/catalyst-gateway/tests/Earthfile b/catalyst-gateway/tests/Earthfile index 979773202f8..85383a20403 100644 --- a/catalyst-gateway/tests/Earthfile +++ b/catalyst-gateway/tests/Earthfile @@ -9,6 +9,6 @@ test-lint-openapi: # Copy the doc artifact. COPY --dir ../+build/doc . # Copy the spectral configuration file. - COPY ./.oapi-v3.spectral.yml .spectral.yml + COPY --dir ./openapi-v3.0-lints/* . # Scan the doc directory where type of file is JSON. DO spectral-ci+LINT --dir=./doc diff --git a/catalyst-gateway/tests/api_tests/api_tests/get_cardano_assets.hurl b/catalyst-gateway/tests/api_tests/api_tests/get_cardano_assets.hurl new file mode 100644 index 00000000000..92bfc53e2ef --- /dev/null +++ b/catalyst-gateway/tests/api_tests/api_tests/get_cardano_assets.hurl @@ -0,0 +1,15 @@ +# Get staked ADA amount: zero assets +GET http://localhost:3030/api/draft/cardano/assets/stake_test1ursne3ndzr4kz8gmhmstu5026erayrnqyj46nqkkfcn0ufss2t7vt +HTTP 200 +{"persistent":{"ada_amount":9809147618,"native_tokens":[],"slot_number":76323283},"volatile":{"ada_amount":0,"native_tokens":[],"slot_number":0}} + +# Get staked ADA amount: single asset +GET http://localhost:3030/api/draft/cardano/assets/stake_test1uq7cnze6az9f8ffjrvkxx4ad77jz088frkhzupxcc7y4x8q5x808s +HTTP 200 +{"persistent":{"ada_amount":8870859858,"native_tokens":[{"amount":3,"asset_name":"GoldRelic","policy_hash":"0x2862c9b33e98096107e2d8b8c072070834db9c91c0d2f3743e75df65"}],"slot_number":76572358},"volatile":{"ada_amount":0,"native_tokens":[],"slot_number":0}} + +# Get staked ADA amount: multiple assets +GET http://localhost:3030/api/draft/cardano/assets/stake_test1ur66dds0pkf3j5tu7py9tqf7savpv7pgc5g3dd74xy0x2vsldf2mx +HTTP 200 +[Asserts] +jsonpath "$.persistent.native_tokens" count == 9 \ No newline at end of file diff --git a/catalyst-gateway/tests/.oapi-v3.spectral.yml b/catalyst-gateway/tests/openapi-v3.0-lints/.spectral.yml similarity index 93% rename from catalyst-gateway/tests/.oapi-v3.spectral.yml rename to catalyst-gateway/tests/openapi-v3.0-lints/.spectral.yml index 9d8710175cc..3470dbb1204 100644 --- a/catalyst-gateway/tests/.oapi-v3.spectral.yml +++ b/catalyst-gateway/tests/openapi-v3.0-lints/.spectral.yml @@ -13,6 +13,10 @@ extends: formats: ["oas3"] +functions: + - "debug" + - "description-required" + aliases: # From: https://github.com/stoplightio/spectral-owasp-ruleset/blob/26819e80e5ac4571b6271834fc97f0a1b66110bd/src/ruleset.ts#L60 StringProperties: @@ -74,6 +78,8 @@ overrides: owasp:api3:2023-no-additionalProperties: error owasp:api3:2023-constrained-additionalProperties: error owasp:api2:2023-read-restricted: error + # Replaced by custom rule `description-required` + oas3-parameter-description: off # Not enforced at OpenAPI level. Production URL's will always be https. owasp:api8:2023-no-server-http: off # Can't add custom properties to server list. @@ -174,23 +180,9 @@ rules: severity: error 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].*" - - field: "description" - function: pattern - functionOptions: - # Matches against a full stop or a literal `*` at the end of a description. - match: "[\\.\\*]$" + function: description-required + functionOptions: + length: 20 api-path: message: "Invalid API path - should be /api/draft/* or /api/v/*" diff --git a/catalyst-gateway/tests/openapi-v3.0-lints/functions/debug.js b/catalyst-gateway/tests/openapi-v3.0-lints/functions/debug.js new file mode 100644 index 00000000000..d57c01a1e6b --- /dev/null +++ b/catalyst-gateway/tests/openapi-v3.0-lints/functions/debug.js @@ -0,0 +1,28 @@ +// Debug target. +// Always fails, message is all the parameters it received. +import { createRulesetFunction } from "@stoplight/spectral-core"; + +export default createRulesetFunction( + { + input: null, + options: { + type: 'object', + properties: { + context: { + type: 'boolean', + description: 'Debug print the context', + default: false + }, + }, + additionalProperties: true + } + }, + (input, options, context) => { + console.log('------ DEBUG ----------------------------------------------------------------') + console.log('input', input); + console.log('options', options); + if (options.context) { + console.log('context', context); + } + }, +); \ No newline at end of file diff --git a/catalyst-gateway/tests/openapi-v3.0-lints/functions/description-required.js b/catalyst-gateway/tests/openapi-v3.0-lints/functions/description-required.js new file mode 100644 index 00000000000..effabdf9e29 --- /dev/null +++ b/catalyst-gateway/tests/openapi-v3.0-lints/functions/description-required.js @@ -0,0 +1,117 @@ +import { createRulesetFunction } from "@stoplight/spectral-core"; +import { printValue } from '@stoplight/spectral-runtime'; + +// regex in a string like {"match": "/[a-b]+/im"} or {"match": "[a-b]+"} in a json ruleset +// the available flags are "gimsuy" as described here: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp +const REGEXP_PATTERN = /^\/(.+)\/([a-z]*)$/; + +const cache = new Map(); + +function getFromCache(pattern) { + const existingPattern = cache.get(pattern); + if (existingPattern !== void 0) { + existingPattern.lastIndex = 0; + return existingPattern; + } + + const newPattern = createRegex(pattern); + cache.set(pattern, newPattern); + return newPattern; +} + +function createRegex(pattern) { + const splitRegex = REGEXP_PATTERN.exec(pattern); + if (splitRegex !== null) { + // with slashes like /[a-b]+/ and possibly with flags like /[a-b]+/im + return new RegExp(splitRegex[1], splitRegex[2]); + } else { + // without slashes like [a-b]+ + return new RegExp(pattern); + } +} + +export default createRulesetFunction( + { + input: null, + options: { + type: 'object', + properties: { + length: { + type: 'integer', + description: 'The minimum length of a description.', + }, + match: { + type: 'string', + description: 'regex that target must match.', + }, + noMatch: { + type: 'string', + description: 'regex that target must not match.', + }, + }, + additionalProperties: false, + }, + }, + (input, options, context) => { + let results = []; + const { } = options; + + const testDescriptionValidity = (value) => { + if (!value) { + (results ??= []).push({ + message: `Description must exist`, + }); + } + + if ('length' in options) { + if (value.length < options.length) { + (results ??= []).push({ + message: `Description must have length >= ${printValue(options.length)} characters`, + }) + } + } + + if ('match' in options) { + const pattern = getFromCache(options.match); + + if (!pattern.test(value)) { + (results ??= []).push({ + message: `${printValue(value)} must match the pattern ${printValue(options.match)}`, + }) + } + } + + if ('noMatch' in options) { + const pattern = getFromCache(options.noMatch); + + if (pattern.test(value)) { + (results ??= []).push({ + message: `${printValue(value)} must NOT match the pattern ${printValue(options.noMatch)}`, + }) + } + } + }; + + // check if 'description' or 'schema.description' exists in the ParameterObject + if (input.description) { + testDescriptionValidity(input.description); + } else if ("in" in input && input.in === "query") { + if ("schema" in input && "description" in input.schema) { + testDescriptionValidity(input.schema.description); + } else { + (results ??= []).push({ + message: `'description' or 'schema.description' is missing in the Query Parameter.` + }) + } + } + else { + (results ??= []).push({ + message: `'description' is missing.` + }) + } + + if (results.length) { + return results; + } + }, +); diff --git a/catalyst_voices/README.md b/catalyst_voices/README.md index 9871aab483a..266da52c97a 100644 --- a/catalyst_voices/README.md +++ b/catalyst_voices/README.md @@ -43,7 +43,7 @@ This repository contains the Catalyst Voices app and packages. ```sh git clone https://github.com/input-output-hk/catalyst-voices.git cd catalyst_voices -melos bootstrap +just bootstrap ``` ### Packages diff --git a/catalyst_voices/apps/voices/pubspec.yaml b/catalyst_voices/apps/voices/pubspec.yaml index 1dd7683242f..27987e9e804 100644 --- a/catalyst_voices/apps/voices/pubspec.yaml +++ b/catalyst_voices/apps/voices/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: path: ../../packages/internal/catalyst_voices_view_models collection: ^1.18.0 dotted_border: ^2.1.0 - equatable: ^2.0.5 + equatable: ^2.0.7 file_picker: ^8.0.7 flutter: sdk: flutter diff --git a/catalyst_voices/justfile b/catalyst_voices/justfile index a0086e28447..9d20ac33cac 100755 --- a/catalyst_voices/justfile +++ b/catalyst_voices/justfile @@ -1,10 +1,22 @@ #!/usr/bin/env just --justfile - # cspell: words justfile default: @just --list --unsorted +# Linking of all packages +setup-code: + melos bs + +# Builds generated code +generate-code: setup-code + melos l10n + melos build_runner + just generate-gateway-services + +# Syntax sugar for linking packages and building generated code +bootstrap: generate-code + # Runs all static code checks check-code: earthly +check-static-analysis diff --git a/catalyst_voices/melos.yaml b/catalyst_voices/melos.yaml index ccc055e9265..14510cecc2d 100644 --- a/catalyst_voices/melos.yaml +++ b/catalyst_voices/melos.yaml @@ -92,7 +92,7 @@ command: cryptography: ^2.7.0 dotted_border: ^2.1.0 ed25519_hd_key: ^2.3.0 - equatable: ^2.0.5 + equatable: ^2.0.7 ffi: ^2.1.0 ffigen: ^11.0.0 file_picker: ^8.0.7 diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_blocs/pubspec.yaml index d8ba90d88d1..f420d231cf7 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/pubspec.yaml @@ -26,7 +26,7 @@ dependencies: catalyst_voices_view_models: path: ../catalyst_voices_view_models collection: ^1.18.0 - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter flutter_bloc: ^8.1.5 diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/user/account_role.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/user/account_role.dart index 8c54665e506..43ab75824db 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/user/account_role.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/user/account_role.dart @@ -1,18 +1,18 @@ enum AccountRole { - voter(roleNumber: 0), + /// An account role that is assigned to every account. + /// Allows to vote for proposals. + voter(number: 0), - // TODO(dtscalac): the RBAC specification doesn't define yet the role number - // for the proposer, replace this arbitrary number when it's specified. - proposer(roleNumber: 1), + /// A delegated representative that can vote on behalf of other accounts. + drep(number: 1), - // TODO(dtscalac): the RBAC specification doesn't define yet the role number - // for the drep, replace this arbitrary number when it's specified. - drep(roleNumber: 2); + /// An account role that can create new proposals. + proposer(number: 3); /// The RBAC specified role number. - final int roleNumber; + final int number; - const AccountRole({required this.roleNumber}); + const AccountRole({required this.number}); /// Returns the role which is assigned to every user. static AccountRole get root => voter; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_models/pubspec.yaml index 1cf08696dfb..e83c92fefdd 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_models/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: catalyst_cardano_web: ^0.3.0 collection: ^1.18.0 convert: ^3.1.1 - equatable: ^2.0.5 + equatable: ^2.0.7 meta: ^1.10.0 password_strength: ^0.2.0 diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/crypto/key_derivation.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/crypto/key_derivation.dart index 8832c6f41e8..25baf7f7c3e 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/crypto/key_derivation.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/crypto/key_derivation.dart @@ -3,6 +3,10 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; /// Derives key pairs from a seed phrase. final class KeyDerivation { + /// See: https://github.com/input-output-hk/catalyst-voices/pull/1300 + static const int _purpose = 508; + static const int _type = 139; + static const int _account = 0; // Future Use final CatalystKeyDerivation _keyDerivation; const KeyDerivation(this._keyDerivation); @@ -44,9 +48,7 @@ final class KeyDerivation { /// The path feed into key derivation algorithm /// to generate a key pair from a seed phrase. - /// - // TODO(dtscalac): update when RBAC specifies it String _roleKeyDerivationPath(AccountRole role) { - return "m/${role.roleNumber}'/1234'"; + return "m/$_purpose'/$_type'/$_account'/${role.number}/0"; } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart index 9a6ad8e7559..171fb17495f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/keychain/vault_keychain.dart @@ -127,7 +127,4 @@ final class VaultKeychain extends SecureStorageVault implements Keychain { @override String toString() => 'VaultKeychain[$id]'; - - @override - List get props => [id]; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart index 6d19bbbbf57..173dbeb38cf 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/registration/registration_transaction_builder.dart @@ -75,24 +75,21 @@ final class RegistrationTransactionBuilder { txInputsHash: TransactionInputsHash.fromTransactionInputs(utxos), chunkedData: RegistrationData( derCerts: [derCert], - publicKeys: [keyPair.publicKey], + publicKeys: [keyPair.publicKey.toPublicKey()], roleDataSet: { // TODO(dtscalac): currently we only support the voter account role, // regardless of selected roles // TODO(dtscalac): when RBAC specification will define other roles // they should be registered here RoleData( - roleNumber: AccountRole.root.roleNumber, - roleSigningKey: KeyReference( - localRef: const LocalKeyReference( - keyType: LocalKeyReferenceType.x509Certs, - keyOffset: 0, - ), + roleNumber: AccountRole.root.number, + roleSigningKey: const LocalKeyReference( + keyType: LocalKeyReferenceType.x509Certs, + offset: 0, ), - roleEncryptionKey: KeyReference( - hash: CertificateHash.fromX509DerCertificate(derCert), - ), - paymentKey: 0, + // Refer to first key in transaction outputs, + // in our case it's the change address (which the user controls). + paymentKey: -1, ), }, ), @@ -143,12 +140,12 @@ final class RegistrationTransactionBuilder { /* cSpell:disable */ const issuer = X509DistinguishedName( - countryName: 'US', - stateOrProvinceName: 'California', - localityName: 'San Francisco', - organizationName: 'MyCompany', - organizationalUnitName: 'MyDepartment', - commonName: 'mydomain.com', + countryName: '', + stateOrProvinceName: '', + localityName: '', + organizationName: '', + organizationalUnitName: '', + commonName: '', ); final tbs = X509TBSCertificate( @@ -160,7 +157,10 @@ final class RegistrationTransactionBuilder { subject: issuer, extensions: X509CertificateExtensions( subjectAltName: [ - 'web+cardano://addr/${_stakeAddress.toBech32()}', + X509String( + 'web+cardano://addr/${_stakeAddress.toBech32()}', + tag: X509String.uriTag, + ), ], ), ); diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart index 00e91b49872..7bef14095d4 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart @@ -6,7 +6,6 @@ import 'package:catalyst_voices_services/src/crypto/crypto_service.dart'; import 'package:catalyst_voices_services/src/crypto/vault_crypto_service.dart'; import 'package:catalyst_voices_services/src/storage/storage_string_mixin.dart'; import 'package:catalyst_voices_services/src/storage/vault/vault.dart'; -import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -14,9 +13,8 @@ const _lockKey = 'LockKey'; /// Implementation of [Vault] that uses [FlutterSecureStorage] as /// facade for read/write operations. -base class SecureStorageVault - with StorageAsStringMixin, EquatableMixin - implements Vault { +base class SecureStorageVault with StorageAsStringMixin implements Vault { + @override final String id; @protected final FlutterSecureStorage secureStorage; @@ -171,6 +169,11 @@ base class SecureStorageVault } } + @override + String toString() { + return 'SecureStorageVault{id: $id}'; + } + /// Allows operation only when [isUnlocked] it true, otherwise returns null. /// /// Returns value assigned to [key]. May return null if not found for [key]. @@ -246,7 +249,4 @@ base class SecureStorageVault void _erase(Uint8List list) { list.fillRange(0, list.length, 0); } - - @override - List get props => [id]; } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart index 8d03c3ee084..67bbbb620d1 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/storage/vault/vault.dart @@ -7,4 +7,6 @@ import 'package:catalyst_voices_services/src/storage/storage.dart'; /// /// In order to unlock [Vault] sufficient [LockFactor] have to be /// set via [unlock] that can unlock [LockFactor] from [setLock]. -abstract interface class Vault implements Storage, Lockable {} +abstract interface class Vault implements Storage, Lockable { + String get id; +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_services/pubspec.yaml index cad7dc6701c..9d7f0d025ce 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_services/pubspec.yaml @@ -20,7 +20,7 @@ dependencies: convert: ^3.1.1 cryptography: ^2.7.0 ed25519_hd_key: ^2.3.0 - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter flutter_secure_storage: ^9.2.2 diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/crypto/key_derivation_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/crypto/key_derivation_test.dart index 7c9098c8bea..71e6d6a30e5 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/crypto/key_derivation_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/crypto/key_derivation_test.dart @@ -31,7 +31,7 @@ void main() { for (final role in AccountRole.values) { final keyPair = await keyDerivation.deriveKeyPair( masterKey: masterKey, - path: "m/${role.roleNumber}'/1234'", + path: "m/${role.number}'/1234'", ); expect(keyPair, isNotNull); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart index 8d66f091238..f8418f71e06 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_provider_test.dart @@ -24,7 +24,10 @@ void main() { // Then expect(await provider.exists(id), isTrue); - expect([keychain], await provider.getAll()); + expect( + [keychain.id], + await provider.getAll().then((value) => value.map((e) => e.id)), + ); }); test('calling create twice on keychain will empty previous data', () async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart index ccfd397b5a9..6d4d4d542a9 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/keychain/vault_keychain_test.dart @@ -68,7 +68,7 @@ void main() { ); }); - test('are equal when id is matching', () async { + test('are not equal when id is matching', () async { // Given final id = const Uuid().v4(); @@ -77,7 +77,7 @@ void main() { final vaultTwo = VaultKeychain(id: id); // Then - expect(vaultOne, equals(vaultTwo)); + expect(vaultOne, isNot(equals(vaultTwo))); }); test('metadata dates are in UTC', () async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart index 045abedef1e..c4aaecb3b41 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/test/src/user/user_service_test.dart @@ -29,7 +29,7 @@ void main() { // Then final currentKeychain = service.keychain; - expect(currentKeychain, keychain); + expect(currentKeychain?.id, keychain.id); }); test('using different keychain emits update in stream', () async { @@ -48,8 +48,8 @@ void main() { keychainStream, emitsInOrder([ isNull, - keychainOne, - keychainTwo, + predicate((e) => e.id == keychainOne.id), + predicate((e) => e.id == keychainTwo.id), isNull, ]), ); @@ -75,7 +75,7 @@ void main() { // Then final serviceKeychains = await service.keychains; - expect(serviceKeychains, keychains); + expect(serviceKeychains.map((e) => e.id), keychains.map((e) => e.id)); }); }); @@ -92,7 +92,7 @@ void main() { await service.useLastAccount(); // Then - expect(service.keychain, expectedKeychain); + expect(service.keychain?.id, expectedKeychain.id); }); test('use last account does nothing on clear instance', () async { diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_view_models/pubspec.yaml index 84c2462ecc0..b87ce58469f 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/pubspec.yaml @@ -17,7 +17,7 @@ dependencies: path: ../catalyst_voices_localization catalyst_voices_models: path: ../catalyst_voices_models - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter formz: ^0.7.0 diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart index a69ef66586f..a3695842c7c 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart @@ -103,20 +103,15 @@ Future> _buildMetadataEnvelope({ previousTransactionId: _transactionHash, chunkedData: RegistrationData( derCerts: [derCert], - publicKeys: [keyPair.publicKey], + publicKeys: [keyPair.publicKey.toPublicKey()], roleDataSet: { RoleData( roleNumber: 0, - roleSigningKey: KeyReference( - localRef: const LocalKeyReference( - keyType: LocalKeyReferenceType.x509Certs, - keyOffset: 0, - ), + roleSigningKey: const LocalKeyReference( + keyType: LocalKeyReferenceType.x509Certs, + offset: 0, ), - roleEncryptionKey: KeyReference( - hash: CertificateHash.fromX509DerCertificate(derCert), - ), - paymentKey: 0, + paymentKey: -1, roleSpecificData: { 10: CborString('Test'), }, @@ -200,12 +195,12 @@ Future generateX509Certificate({ /* cSpell:disable */ const issuer = X509DistinguishedName( - countryName: 'US', - stateOrProvinceName: 'California', - localityName: 'San Francisco', - organizationName: 'MyCompany', - organizationalUnitName: 'MyDepartment', - commonName: 'mydomain.com', + countryName: '', + stateOrProvinceName: '', + localityName: '', + organizationName: '', + organizationalUnitName: '', + commonName: '', ); final tbs = X509TBSCertificate( @@ -217,11 +212,14 @@ Future generateX509Certificate({ subject: issuer, extensions: X509CertificateExtensions( subjectAltName: [ - 'mydomain.com', - 'www.mydomain.com', - 'example.com', - 'www.example.com', - 'web+cardano://addr/${stakeAddress.toBech32()}', + const X509String('mydomain.com', tag: X509String.domainNameTag), + const X509String('www.mydomain.com', tag: X509String.domainNameTag), + const X509String('example.com', tag: X509String.domainNameTag), + const X509String('www.example.com', tag: X509String.domainNameTag), + X509String( + 'web+cardano://addr/${stakeAddress.toBech32()}', + tag: X509String.uriTag, + ), ], ), ); diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/pubspec.yaml b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/pubspec.yaml index eaa42e72f1e..0a30d979fe4 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/pubspec.yaml +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/example/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: cbor: ^6.2.0 convert: ^3.1.1 cupertino_icons: ^1.0.6 - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano_platform_interface/pubspec.yaml b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano_platform_interface/pubspec.yaml index 9f97f5edb55..d2c0f8fb23b 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano_platform_interface/pubspec.yaml +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano_platform_interface/pubspec.yaml @@ -11,7 +11,7 @@ environment: dependencies: catalyst_cardano_serialization: ^0.4.0 - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter plugin_platform_interface: ^2.1.7 diff --git a/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/registration_data.dart b/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/registration_data.dart index 17af8f6ac8e..972c3fc4286 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/registration_data.dart +++ b/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/registration_data.dart @@ -16,7 +16,7 @@ final class RegistrationData extends Equatable implements CborEncodable { final List? cborCerts; /// Ordered list of simple public keys that are registered. - final List? publicKeys; + final List? publicKeys; /// Revocation list of certs being revoked by an issuer. final List? revocationSet; @@ -45,9 +45,7 @@ final class RegistrationData extends Equatable implements CborEncodable { return RegistrationData( derCerts: derCerts?.map(X509DerCertificate.fromCbor).toList(), cborCerts: cborCerts?.map(C509Certificate.fromCbor).toList(), - publicKeys: publicKeys - ?.map(Bip32Ed25519XPublicKeyFactory.instance.fromCbor) - .toList(), + publicKeys: publicKeys?.map(Ed25519PublicKey.fromCbor).toList(), revocationSet: revocationSet?.map(CertificateHash.fromCbor).toList(), roleDataSet: roleDataSet?.map(RoleData.fromCbor).toSet(), ); @@ -68,7 +66,7 @@ final class RegistrationData extends Equatable implements CborEncodable { cborCerts, (item) => item.toCbor(), ), - const CborSmallInt(30): _createCborList( + const CborSmallInt(30): _createCborList( publicKeys, (item) => item.toCbor(tags: [CborCustomTags.ed25519Bip32PublicKey]), ), @@ -142,7 +140,7 @@ class RoleData extends Equatable implements CborEncodable { /// /// If the certificate is revoked, the role is unusable for signing unless /// and until a new signing certificate is registered for the role. - final KeyReference? roleSigningKey; + final LocalKeyReference? roleSigningKey; /// A Role may require the ability to transfer encrypted data. /// The registration can include the Public key use by the role to encrypt @@ -157,7 +155,7 @@ class RoleData extends Equatable implements CborEncodable { /// key encryption, and not just a signing key. /// If the key referenced does not support public key encryption, /// the registration is invalid. - final KeyReference? roleEncryptionKey; + final LocalKeyReference? roleEncryptionKey; /// Reference to a transaction input/output as the payment key to use for a role. /// Payment key (n) >= 0 = Use Transaction Input Key offset (n) @@ -209,10 +207,11 @@ class RoleData extends Equatable implements CborEncodable { return RoleData( roleNumber: roleNumber.value, - roleSigningKey: - roleSigningKey != null ? KeyReference.fromCbor(roleSigningKey) : null, + roleSigningKey: roleSigningKey != null + ? LocalKeyReference.fromCbor(roleSigningKey) + : null, roleEncryptionKey: roleEncryptionKey != null - ? KeyReference.fromCbor(roleEncryptionKey) + ? LocalKeyReference.fromCbor(roleEncryptionKey) : null, paymentKey: paymentKey?.value, roleSpecificData: roleSpecificData.isNotEmpty @@ -248,61 +247,6 @@ class RoleData extends Equatable implements CborEncodable { ]; } -/// References a local key in this registration or -/// a given key in an earlier registration. -/// -/// Either [localRef] or [hash] must be set, but not both and not none. -class KeyReference extends Equatable implements CborEncodable { - /// Offset reference to a key defined in this registration. - /// More efficient than a key hash. - final LocalKeyReference? localRef; - - /// Reference to a key defined in an earlier registration. - final CertificateHash? hash; - - /// The default constructor for [KeyReference]. - KeyReference({this.localRef, this.hash}) { - if (!((localRef == null) ^ (hash == null))) { - throw ArgumentError( - 'Either localRef or hash must be set, but not both and not none.', - ); - } - } - - /// Deserializes the type from cbor. - factory KeyReference.fromCbor(CborValue value) { - return KeyReference( - localRef: _tryParseLocalRef(value), - hash: _tryParseHash(value), - ); - } - - static LocalKeyReference? _tryParseLocalRef(CborValue value) { - try { - return LocalKeyReference.fromCbor(value); - } catch (ignored) { - return null; - } - } - - static CertificateHash? _tryParseHash(CborValue value) { - try { - return CertificateHash.fromCbor(value); - } catch (ignored) { - return null; - } - } - - /// Serializes the type as cbor. - @override - CborValue toCbor() { - return localRef?.toCbor() ?? hash!.toCbor(); - } - - @override - List get props => [localRef, hash]; -} - /// Offset reference to a key defined in this registration. /// /// More efficient than a key hash. @@ -311,23 +255,23 @@ class LocalKeyReference extends Equatable implements CborEncodable { final LocalKeyReferenceType keyType; /// Offset of the key in the specified set. 0 = first entry. - final int keyOffset; + final int offset; /// The default constructor for [LocalKeyReference]. const LocalKeyReference({ required this.keyType, - required this.keyOffset, + required this.offset, }); /// Deserializes the type from cbor. factory LocalKeyReference.fromCbor(CborValue value) { final list = value as CborList; final keyType = list[0] as CborSmallInt; - final keyOffset = list[1] as CborSmallInt; + final offset = list[1] as CborSmallInt; return LocalKeyReference( keyType: LocalKeyReferenceType.fromTag(keyType.value), - keyOffset: keyOffset.value, + offset: offset.value, ); } @@ -336,12 +280,12 @@ class LocalKeyReference extends Equatable implements CborEncodable { CborValue toCbor() { return CborList([ CborSmallInt(keyType.tag), - CborSmallInt(keyOffset), + CborSmallInt(offset), ]); } @override - List get props => [keyType, keyOffset]; + List get props => [keyType, offset]; } /// Defines the type of the referenced local key. diff --git a/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/x509_certificate.dart b/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/x509_certificate.dart index dbf6f7c7eb1..a4a9e00ce9b 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/x509_certificate.dart +++ b/catalyst_voices/packages/libs/catalyst_cardano_serialization/lib/src/rbac/x509_certificate.dart @@ -245,54 +245,72 @@ class X509DistinguishedName with EquatableMixin { final countryName = this.countryName; if (countryName != null) { sequence.add( - ASN1Sequence() - ..add(ASN1ObjectIdentifier.fromName('c')) - ..add(ASN1PrintableString(countryName)), + ASN1Set() + ..add( + ASN1Sequence() + ..add(ASN1ObjectIdentifier.fromName('c')) + ..add(ASN1PrintableString(countryName)), + ), ); } final stateOrProvinceName = this.stateOrProvinceName; if (stateOrProvinceName != null) { sequence.add( - ASN1Sequence() - ..add(ASN1ObjectIdentifier.fromName('st')) - ..add(ASN1PrintableString(stateOrProvinceName)), + ASN1Set() + ..add( + ASN1Sequence() + ..add(ASN1ObjectIdentifier.fromName('st')) + ..add(ASN1PrintableString(stateOrProvinceName)), + ), ); } final localityName = this.localityName; if (localityName != null) { sequence.add( - ASN1Sequence() - ..add(ASN1ObjectIdentifier.fromName('l')) - ..add(ASN1PrintableString(localityName)), + ASN1Set() + ..add( + ASN1Sequence() + ..add(ASN1ObjectIdentifier.fromName('l')) + ..add(ASN1PrintableString(localityName)), + ), ); } final organizationName = this.organizationName; if (organizationName != null) { sequence.add( - ASN1Sequence() - ..add(ASN1ObjectIdentifier.fromName('o')) - ..add(ASN1PrintableString(organizationName)), + ASN1Set() + ..add( + ASN1Sequence() + ..add(ASN1ObjectIdentifier.fromName('o')) + ..add(ASN1PrintableString(organizationName)), + ), ); } final organizationalUnitName = this.organizationalUnitName; if (organizationalUnitName != null) { sequence.add( - ASN1Sequence() - ..add(ASN1ObjectIdentifier.fromName('ou')) - ..add(ASN1PrintableString(organizationalUnitName)), + ASN1Set() + ..add( + ASN1Sequence() + ..add(ASN1ObjectIdentifier.fromName('ou')) + ..add(ASN1PrintableString(organizationalUnitName)), + ), ); } final commonName = this.commonName; if (commonName != null) { sequence.add( - ASN1Sequence() - ..add(ASN1ObjectIdentifier.fromName('cn')) - ..add(ASN1PrintableString(commonName)), + ASN1Set() + ..add( + ASN1Sequence() + ..add(ASN1ObjectIdentifier.fromName('cn')) + ..add(ASN1PrintableString(commonName)), + ), ); } @@ -313,7 +331,7 @@ class X509DistinguishedName with EquatableMixin { /// Extra extensions of the certificate. class X509CertificateExtensions with EquatableMixin { /// List of alternative subject names. - final List? subjectAltName; + final List? subjectAltName; /// The default constructor for the [X509CertificateExtensions]. const X509CertificateExtensions({this.subjectAltName}); @@ -330,7 +348,7 @@ class X509CertificateExtensions with EquatableMixin { if (subjectAltName != null) { final subjectAltNameSequence = ASN1Sequence(); for (final name in subjectAltName) { - subjectAltNameSequence.add(ASN1OctetString(name)); + subjectAltNameSequence.add(name.toASN1()); } extensionsSequence.add( @@ -350,3 +368,35 @@ class X509CertificateExtensions with EquatableMixin { @override List get props => [subjectAltName]; } + +/// Represents an ASN1 encodable string +/// that can be optionally tagged with [tag]. +class X509String with EquatableMixin { + /// An ASN1 tag for the uris. + static const int uriTag = 0x86; + + /// An ASN1 tag for domain names. + static const int domainNameTag = 0x82; + + /// The string value. + final String value; + + /// The optional ASN1 tag. + final int tag; + + /// The default constructor for the [X509String]. + const X509String( + this.value, { + this.tag = OCTET_STRING_TYPE, + }); + + /// Encodes the data in ASN1 format. + ASN1Object toASN1() { + _ensureASN1FrequentNamesRegistered(); + + return ASN1OctetString(value, tag: tag); + } + + @override + List get props => [value, tag]; +} diff --git a/catalyst_voices/packages/libs/catalyst_cardano_serialization/pubspec.yaml b/catalyst_voices/packages/libs/catalyst_cardano_serialization/pubspec.yaml index 68cfd6fcc5e..5073d1d1d1d 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano_serialization/pubspec.yaml +++ b/catalyst_voices/packages/libs/catalyst_cardano_serialization/pubspec.yaml @@ -19,7 +19,7 @@ dependencies: cbor: ^6.2.0 convert: ^3.1.1 cryptography: ^2.7.0 - equatable: ^2.0.5 + equatable: ^2.0.7 pinenacl: ^0.6.0 ulid: ^2.0.0 diff --git a/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/registration_data_test.dart b/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/registration_data_test.dart index 0e61184ce03..df486e9cdb2 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/registration_data_test.dart +++ b/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/registration_data_test.dart @@ -1,22 +1,12 @@ import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; import 'package:catalyst_key_derivation/catalyst_key_derivation.dart'; import 'package:cbor/cbor.dart'; -import 'package:equatable/equatable.dart'; -import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; import '../test_utils/test_data.dart'; void main() { group(RegistrationData, () { - setUpAll(() { - Bip32Ed25519XPrivateKeyFactory.instance = - _FakeBip32Ed25519XPrivateKeyFactory(); - - Bip32Ed25519XPublicKeyFactory.instance = - _FakeBip32Ed25519XPublicKeyFactory(); - }); - test('from and to cbor', () { final derCert = X509DerCertificate.fromHex(derCertHex); final c509Cert = C509Certificate.fromHex(c509CertHex); @@ -24,7 +14,7 @@ void main() { final original = RegistrationData( derCerts: [derCert], cborCerts: [c509Cert], - publicKeys: [Bip32Ed25519XPublicKeyFactory.instance.seeded(0)], + publicKeys: [Ed25519PublicKey.seeded(0)], revocationSet: [ CertificateHash.fromX509DerCertificate(derCert), CertificateHash.fromC509Certificate(c509Cert), @@ -32,14 +22,13 @@ void main() { roleDataSet: { RoleData( roleNumber: 0, - roleSigningKey: KeyReference( - localRef: const LocalKeyReference( - keyType: LocalKeyReferenceType.x509Certs, - keyOffset: 0, - ), + roleSigningKey: const LocalKeyReference( + keyType: LocalKeyReferenceType.x509Certs, + offset: 0, ), - roleEncryptionKey: KeyReference( - hash: CertificateHash.fromX509DerCertificate(derCert), + roleEncryptionKey: const LocalKeyReference( + keyType: LocalKeyReferenceType.x509Certs, + offset: 0, ), paymentKey: 0, roleSpecificData: { @@ -55,48 +44,3 @@ void main() { }); }); } - -class _FakeBip32Ed25519XPrivateKeyFactory - extends Bip32Ed25519XPrivateKeyFactory { - @override - Bip32Ed25519XPrivateKey fromBytes(List bytes) { - return _FakeBip32Ed22519XPrivateKey(bytes: bytes); - } -} - -class _FakeBip32Ed25519XPublicKeyFactory extends Bip32Ed25519XPublicKeyFactory { - @override - _FakeBip32Ed25519XPublicKey fromBytes(List bytes) { - return _FakeBip32Ed25519XPublicKey(bytes: bytes); - } -} - -class _FakeBip32Ed22519XPrivateKey extends Fake - implements Bip32Ed25519XPrivateKey { - @override - final List bytes; - - _FakeBip32Ed22519XPrivateKey({required this.bytes}); - - @override - CborValue toCbor() { - return CborBytes(bytes); - } -} - -class _FakeBip32Ed25519XPublicKey extends Fake - with EquatableMixin - implements Bip32Ed25519XPublicKey { - @override - final List bytes; - - _FakeBip32Ed25519XPublicKey({required this.bytes}); - - @override - CborValue toCbor({List tags = const []}) { - return CborBytes(bytes, tags: tags); - } - - @override - List get props => bytes; -} diff --git a/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.dart b/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.dart index eb31671c01d..0c719fb2ad1 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.dart +++ b/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.dart @@ -1,27 +1,13 @@ import 'package:catalyst_cardano_serialization/src/rbac/x509_certificate.dart'; import 'package:catalyst_key_derivation/catalyst_key_derivation.dart'; -import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; -import 'x509_certificate_test.mocks.dart'; - -@GenerateNiceMocks([ - MockSpec(), - MockSpec(), - MockSpec(), -]) void main() { group(X509Certificate, () { - final privateKey = MockBip32Ed25519XPrivateKey(); - final publicKey = MockBip32Ed25519XPublicKey(); - final signature = MockBip32Ed25519XSignature(); - - setUp(() { - // ignore: discarded_futures - when(privateKey.sign(any)).thenAnswer((_) async => signature); - when(signature.bytes).thenReturn([1, 2, 3]); - }); + final signature = _FakeBip32Ed25519XSignature(); + final privateKey = _FakeBip32Ed25519XPrivateKey(signature: signature); + final publicKey = _FakeBip32Ed25519XPublicKey(); test('generateSelfSigned X509 certificate', () async { /* cSpell:disable */ @@ -43,10 +29,10 @@ void main() { subject: issuer, extensions: const X509CertificateExtensions( subjectAltName: [ - 'mydomain.com', - 'www.mydomain.com', - 'example.com', - 'www.example.com', + X509String('mydomain.com', tag: X509String.domainNameTag), + X509String('www.mydomain.com', tag: X509String.domainNameTag), + X509String('example.com', tag: X509String.domainNameTag), + X509String('www.example.com', tag: X509String.domainNameTag), ], ), ); @@ -65,3 +51,27 @@ void main() { }); }); } + +class _FakeBip32Ed25519XPrivateKey extends Fake + implements Bip32Ed25519XPrivateKey { + final Bip32Ed25519XSignature signature; + + _FakeBip32Ed25519XPrivateKey({required this.signature}); + + @override + Future sign(List message) async { + return signature; + } +} + +class _FakeBip32Ed25519XPublicKey extends Fake + implements Bip32Ed25519XPublicKey { + @override + List get bytes => [1, 2, 3]; +} + +class _FakeBip32Ed25519XSignature extends Fake + implements Bip32Ed25519XSignature { + @override + List get bytes => [4, 5, 6]; +} diff --git a/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.mocks.dart b/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.mocks.dart deleted file mode 100644 index cbd52bec42e..00000000000 --- a/catalyst_voices/packages/libs/catalyst_cardano_serialization/test/rbac/x509_certificate_test.mocks.dart +++ /dev/null @@ -1,386 +0,0 @@ -// Mocks generated by Mockito 5.4.4 from annotations -// in catalyst_cardano_serialization/test/rbac/x509_certificate_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i7; - -import 'package:catalyst_key_derivation/src/bip32_ed25519/bip32_ed25519_private_key.dart' - as _i5; -import 'package:catalyst_key_derivation/src/bip32_ed25519/bip32_ed25519_public_key.dart' - as _i4; -import 'package:catalyst_key_derivation/src/bip32_ed25519/bip32_ed25519_signature.dart' - as _i3; -import 'package:cbor/cbor.dart' as _i2; -import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i6; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -class _FakeCborValue_0 extends _i1.SmartFake implements _i2.CborValue { - _FakeCborValue_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeBip32Ed25519XSignature_1 extends _i1.SmartFake - implements _i3.Bip32Ed25519XSignature { - _FakeBip32Ed25519XSignature_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeBip32Ed25519XPublicKey_2 extends _i1.SmartFake - implements _i4.Bip32Ed25519XPublicKey { - _FakeBip32Ed25519XPublicKey_2( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeBip32Ed25519XPrivateKey_3 extends _i1.SmartFake - implements _i5.Bip32Ed25519XPrivateKey { - _FakeBip32Ed25519XPrivateKey_3( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -/// A class which mocks [Bip32Ed25519XPrivateKey]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockBip32Ed25519XPrivateKey extends _i1.Mock - implements _i5.Bip32Ed25519XPrivateKey { - @override - List get bytes => (super.noSuchMethod( - Invocation.getter(#bytes), - returnValue: [], - returnValueForMissingStub: [], - ) as List); - - @override - List get props => (super.noSuchMethod( - Invocation.getter(#props), - returnValue: [], - returnValueForMissingStub: [], - ) as List); - - @override - _i2.CborValue toCbor() => (super.noSuchMethod( - Invocation.method( - #toCbor, - [], - ), - returnValue: _FakeCborValue_0( - this, - Invocation.method( - #toCbor, - [], - ), - ), - returnValueForMissingStub: _FakeCborValue_0( - this, - Invocation.method( - #toCbor, - [], - ), - ), - ) as _i2.CborValue); - - @override - String toHex() => (super.noSuchMethod( - Invocation.method( - #toHex, - [], - ), - returnValue: _i6.dummyValue( - this, - Invocation.method( - #toHex, - [], - ), - ), - returnValueForMissingStub: _i6.dummyValue( - this, - Invocation.method( - #toHex, - [], - ), - ), - ) as String); - - @override - _i7.Future<_i3.Bip32Ed25519XSignature> sign(List? message) => - (super.noSuchMethod( - Invocation.method( - #sign, - [message], - ), - returnValue: _i7.Future<_i3.Bip32Ed25519XSignature>.value( - _FakeBip32Ed25519XSignature_1( - this, - Invocation.method( - #sign, - [message], - ), - )), - returnValueForMissingStub: _i7.Future<_i3.Bip32Ed25519XSignature>.value( - _FakeBip32Ed25519XSignature_1( - this, - Invocation.method( - #sign, - [message], - ), - )), - ) as _i7.Future<_i3.Bip32Ed25519XSignature>); - - @override - _i7.Future verify( - List? message, { - required _i3.Bip32Ed25519XSignature? signature, - }) => - (super.noSuchMethod( - Invocation.method( - #verify, - [message], - {#signature: signature}, - ), - returnValue: _i7.Future.value(false), - returnValueForMissingStub: _i7.Future.value(false), - ) as _i7.Future); - - @override - _i7.Future<_i4.Bip32Ed25519XPublicKey> derivePublicKey() => - (super.noSuchMethod( - Invocation.method( - #derivePublicKey, - [], - ), - returnValue: _i7.Future<_i4.Bip32Ed25519XPublicKey>.value( - _FakeBip32Ed25519XPublicKey_2( - this, - Invocation.method( - #derivePublicKey, - [], - ), - )), - returnValueForMissingStub: _i7.Future<_i4.Bip32Ed25519XPublicKey>.value( - _FakeBip32Ed25519XPublicKey_2( - this, - Invocation.method( - #derivePublicKey, - [], - ), - )), - ) as _i7.Future<_i4.Bip32Ed25519XPublicKey>); - - @override - _i7.Future<_i5.Bip32Ed25519XPrivateKey> derivePrivateKey( - {required String? path}) => - (super.noSuchMethod( - Invocation.method( - #derivePrivateKey, - [], - {#path: path}, - ), - returnValue: _i7.Future<_i5.Bip32Ed25519XPrivateKey>.value( - _FakeBip32Ed25519XPrivateKey_3( - this, - Invocation.method( - #derivePrivateKey, - [], - {#path: path}, - ), - )), - returnValueForMissingStub: - _i7.Future<_i5.Bip32Ed25519XPrivateKey>.value( - _FakeBip32Ed25519XPrivateKey_3( - this, - Invocation.method( - #derivePrivateKey, - [], - {#path: path}, - ), - )), - ) as _i7.Future<_i5.Bip32Ed25519XPrivateKey>); - - @override - void drop() => super.noSuchMethod( - Invocation.method( - #drop, - [], - ), - returnValueForMissingStub: null, - ); -} - -/// A class which mocks [Bip32Ed25519XPublicKey]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockBip32Ed25519XPublicKey extends _i1.Mock - implements _i4.Bip32Ed25519XPublicKey { - @override - List get bytes => (super.noSuchMethod( - Invocation.getter(#bytes), - returnValue: [], - returnValueForMissingStub: [], - ) as List); - - @override - List get props => (super.noSuchMethod( - Invocation.getter(#props), - returnValue: [], - returnValueForMissingStub: [], - ) as List); - - @override - _i2.CborValue toCbor({List? tags = const []}) => (super.noSuchMethod( - Invocation.method( - #toCbor, - [], - {#tags: tags}, - ), - returnValue: _FakeCborValue_0( - this, - Invocation.method( - #toCbor, - [], - {#tags: tags}, - ), - ), - returnValueForMissingStub: _FakeCborValue_0( - this, - Invocation.method( - #toCbor, - [], - {#tags: tags}, - ), - ), - ) as _i2.CborValue); - - @override - String toHex() => (super.noSuchMethod( - Invocation.method( - #toHex, - [], - ), - returnValue: _i6.dummyValue( - this, - Invocation.method( - #toHex, - [], - ), - ), - returnValueForMissingStub: _i6.dummyValue( - this, - Invocation.method( - #toHex, - [], - ), - ), - ) as String); - - @override - _i7.Future verify( - List? message, { - required _i3.Bip32Ed25519XSignature? signature, - }) => - (super.noSuchMethod( - Invocation.method( - #verify, - [message], - {#signature: signature}, - ), - returnValue: _i7.Future.value(false), - returnValueForMissingStub: _i7.Future.value(false), - ) as _i7.Future); -} - -/// A class which mocks [Bip32Ed25519XSignature]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockBip32Ed25519XSignature extends _i1.Mock - implements _i3.Bip32Ed25519XSignature { - @override - List get bytes => (super.noSuchMethod( - Invocation.getter(#bytes), - returnValue: [], - returnValueForMissingStub: [], - ) as List); - - @override - List get props => (super.noSuchMethod( - Invocation.getter(#props), - returnValue: [], - returnValueForMissingStub: [], - ) as List); - - @override - _i2.CborValue toCbor() => (super.noSuchMethod( - Invocation.method( - #toCbor, - [], - ), - returnValue: _FakeCborValue_0( - this, - Invocation.method( - #toCbor, - [], - ), - ), - returnValueForMissingStub: _FakeCborValue_0( - this, - Invocation.method( - #toCbor, - [], - ), - ), - ) as _i2.CborValue); - - @override - String toHex() => (super.noSuchMethod( - Invocation.method( - #toHex, - [], - ), - returnValue: _i6.dummyValue( - this, - Invocation.method( - #toHex, - [], - ), - ), - returnValueForMissingStub: _i6.dummyValue( - this, - Invocation.method( - #toHex, - [], - ), - ), - ) as String); -} diff --git a/catalyst_voices/packages/libs/catalyst_compression/catalyst_compression_platform_interface/pubspec.yaml b/catalyst_voices/packages/libs/catalyst_compression/catalyst_compression_platform_interface/pubspec.yaml index 6f3c9522d43..f9550c6c32a 100644 --- a/catalyst_voices/packages/libs/catalyst_compression/catalyst_compression_platform_interface/pubspec.yaml +++ b/catalyst_voices/packages/libs/catalyst_compression/catalyst_compression_platform_interface/pubspec.yaml @@ -10,7 +10,7 @@ environment: flutter: ">=3.24.1" dependencies: - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter plugin_platform_interface: ^2.1.7 diff --git a/catalyst_voices/packages/libs/catalyst_key_derivation/example/integration_test/catalyst_key_derivation_test.dart b/catalyst_voices/packages/libs/catalyst_key_derivation/example/integration_test/catalyst_key_derivation_test.dart index 2c5decf688b..ac73817a35e 100644 --- a/catalyst_voices/packages/libs/catalyst_key_derivation/example/integration_test/catalyst_key_derivation_test.dart +++ b/catalyst_voices/packages/libs/catalyst_key_derivation/example/integration_test/catalyst_key_derivation_test.dart @@ -54,6 +54,7 @@ void main() { final xprv = await keyDerivation.deriveMasterKey(mnemonic: mnemonic); final xpub = await xprv.derivePublicKey(); + final pub = xpub.toPublicKey(); const data = [1, 2, 3, 4]; final sig = await xprv.sign(data); @@ -63,6 +64,13 @@ void main() { final xpubVerification = await xpub.verify(data, signature: sig); expect(xpubVerification, isTrue); + + final pubVerification = await pub.verify( + data, + signature: Ed25519Signature.fromBytes(sig.bytes), + ); + + expect(pubVerification, isTrue); }); testWidgets('derivePrivateKey', (tester) async { diff --git a/catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/bip32_ed25519/bip32_ed25519_public_key.dart b/catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/bip32_ed25519/bip32_ed25519_public_key.dart index 3d32ebef57c..577092f990a 100644 --- a/catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/bip32_ed25519/bip32_ed25519_public_key.dart +++ b/catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/bip32_ed25519/bip32_ed25519_public_key.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'package:catalyst_key_derivation/src/bip32_ed25519/bip32_ed25519_signature.dart'; +import 'package:catalyst_key_derivation/src/ed25519/ed25519_public_key.dart'; import 'package:catalyst_key_derivation/src/rust/api/key_derivation.dart' as rust; import 'package:cbor/cbor.dart'; @@ -31,6 +32,10 @@ class Bip32Ed25519XPublicKey extends Equatable { /// Returns the bytes of the public key. List get bytes => _bytes.inner; + /// Extracts the public key bytes from the extended public key. + Ed25519PublicKey toPublicKey() => + Ed25519PublicKey.fromBytes(_bytes.publicKey); + /// Verifies whether a given [signature] was created using this public key /// for the provided [message]. /// diff --git a/catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/ed25519/ed25519_public_key.dart b/catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/ed25519/ed25519_public_key.dart index f295da8dfe7..8a8ed253dd5 100644 --- a/catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/ed25519/ed25519_public_key.dart +++ b/catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/ed25519/ed25519_public_key.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_key_derivation/catalyst_key_derivation.dart'; import 'package:catalyst_key_derivation/src/ed25519/ed25519_signature.dart'; import 'package:cbor/cbor.dart'; import 'package:convert/convert.dart'; diff --git a/catalyst_voices/packages/libs/catalyst_key_derivation/pubspec.yaml b/catalyst_voices/packages/libs/catalyst_key_derivation/pubspec.yaml index cb2b9ae38d7..d79f3ae8c58 100644 --- a/catalyst_voices/packages/libs/catalyst_key_derivation/pubspec.yaml +++ b/catalyst_voices/packages/libs/catalyst_key_derivation/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: cbor: ^6.2.0 convert: ^3.1.1 cryptography: ^2.7.0 - equatable: ^2.0.5 + equatable: ^2.0.7 flutter: sdk: flutter flutter_rust_bridge: 2.5.1 diff --git a/docs/src/api/cat-gateway/stoplight_template.html b/docs/src/api/cat-gateway/stoplight_template.html index 582d1db5a62..d0c263bc938 100644 --- a/docs/src/api/cat-gateway/stoplight_template.html +++ b/docs/src/api/cat-gateway/stoplight_template.html @@ -22,9 +22,10 @@ Catalyst Gateway Rust Docs - - + + diff --git a/docs/src/architecture/08_concepts/key-derivation/.pages b/docs/src/architecture/08_concepts/key-derivation/.pages new file mode 100644 index 00000000000..b37fa5b0b99 --- /dev/null +++ b/docs/src/architecture/08_concepts/key-derivation/.pages @@ -0,0 +1,3 @@ +title: Key Derivation +arrange: + - derivation.md diff --git a/docs/src/architecture/08_concepts/key-derivation/derivation.md b/docs/src/architecture/08_concepts/key-derivation/derivation.md new file mode 100644 index 00000000000..96f2d9a18b1 --- /dev/null +++ b/docs/src/architecture/08_concepts/key-derivation/derivation.md @@ -0,0 +1,90 @@ +--- +Title: Catalyst HD Key Derivation for Off Chain ED25519 Signature Keys +Category: Catalyst +Status: Proposed +Authors: + - Steven Johnson +Implementors: + - Catalyst Fund 14 +Discussions: [] +Created: 2024-11-29 +License: CC-BY-4.0 +--- + +## Abstract + +Project Catalyst uses off chain keys, as a proxy for on-chain keys. +These keys need to be derived similar to the keys controlled by a wallet. +This document defines the Derivation path. + +## Motivation: why is this CIP necessary? + +A user will need a number of self generated and controlled signature keys. +They will need to be able to recover them from a known seed phrase, and also to roll them over. + +This allows users to replace keys, and have them fully recoverable. +Which they may have to do if: + +* Their keys are lost, and the account has to be recovered, or moved to a different device. +* Their keys are compromised (or suspected to be compromised), and they have to be replaced. + +The keys are not controlled by a Blockchain wallet. +They are agnostic of any blockchain. +So, Project Catalyst must implement similar mechanisms as the wallets to safely derive keys for its use. + +## Specification + +For reference, see [CIP-1852]. +This document is a modified implementation of this specification. + +The basic structure of the Key Derivation path shall be: + +```text +m / purpose' / type' / account' / role / index +``` + +In Cardano, both `purpose'` and `coin_type'` are notable years. +This specification uses [historical dates] related to democracy and voting. +This specification follows that convention but choose years more applicable to project Catalyst and its goals. +This specification also renames `coin_type'` to just `type'`, to be more generalized. + +Changing the `purpose` and `type` values ensures that any keys derived for Project Catalyst will not +be derivable or collide with keys derived for Cardano. + +* `purpose'` = `508` : Taken from year 508 BCE, the first known instance of democracy in human history. + *"The Athenian Revolution, + a revolt that overthrew the aristocratic oligarchy and established a participatory democracy in Athens"*. +* `type'` = `139` : Taken from the year 139 BCE, the first known instance of secret voting. + *"A secret ballot is instituted for Roman citizens, who mark their vote on a tablet and place it in an urn."* +* `account'` = `0` : Reserved for future use cases. + Always to be set to `0`. +* `role` = `0`-`n` : The role in the derivation maps 1:1 with the role number in the RBAC registration the key will be used for. +* `index` = `0`-`n` : The sequentially derived key in a sequence, starting at 0. + Each new key for the same role just increments `index`. + +## Reference Implementation + +The first implementation will be Catalyst Voices. + +*TODO: Generate a set of test vectors which conform to this specification.* + +## Rationale: how does this CIP achieve its goals? + +By leveraging known working Key Derivation techniques and simply modifying the path we inherit the properties of those methods. + +## Path to Active + +### Acceptance Criteria + +Working Implementation before Fund 14. + +### Implementation Plan + +Fund 14 project catalyst will deploy this scheme for Key derivation.> + +## Copyright + +This document is licensed under [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/legalcode). + +[CIP-1852]: https://cips.cardano.org/cip/CIP-1852 +[historical dates]: https://www.oxfordreference.com/display/10.1093/acref/9780191737152.timeline.0001