diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 2a20967c22..52715d4bb0 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -1,6 +1,7 @@ aapt aarch abnf +addr addrr adminer afinet diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a83c0527f..0d887d7800 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,13 +10,6 @@ permissions: jobs: ci: - uses: input-output-hk/catalyst-ci/.github/workflows/ci.yml@master + uses: input-output-hk/catalyst-forge/.github/workflows/ci.yml@master with: - aws_ecr_registry: 332405224602.dkr.ecr.eu-central-1.amazonaws.com - aws_role_arn: arn:aws:iam::332405224602:role/ci - aws_region: eu-central-1 - secrets: - dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }} - dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} - earthly_runner_address: ${{ secrets.EARTHLY_SATELLITE_ADDRESS }} - earthly_runner_secret: ${{ secrets.EARTHLY_RUNNER_SECRET }} \ No newline at end of file + forge_version: latest \ No newline at end of file diff --git a/.github/workflows/nightly-ci.yml b/.github/workflows/nightly-ci.yml index 367c0af318..71e4bc44db 100644 --- a/.github/workflows/nightly-ci.yml +++ b/.github/workflows/nightly-ci.yml @@ -68,7 +68,7 @@ jobs: if: always() continue-on-error: true with: - earthfile: ./catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/ + earthfile: ./catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/ flags: --allow-privileged targets: nightly-test target_flags: diff --git a/Earthfile b/Earthfile index 95f6e0ee2c..306dcf3452 100644 --- a/Earthfile +++ b/Earthfile @@ -1,8 +1,8 @@ VERSION 0.8 -IMPORT github.com/input-output-hk/catalyst-ci/earthly/mdlint:v3.2.03 AS mdlint-ci -IMPORT github.com/input-output-hk/catalyst-ci/earthly/cspell:v3.2.03 AS cspell-ci -IMPORT github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.2.03 AS postgresql-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/mdlint:v3.2.07 AS mdlint-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/cspell:v3.2.07 AS cspell-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.2.07 AS postgresql-ci FROM debian:stable-slim diff --git a/blueprint.cue b/blueprint.cue new file mode 100644 index 0000000000..193d3916f6 --- /dev/null +++ b/blueprint.cue @@ -0,0 +1,51 @@ +version: "1.0" +global: { + ci: { + local: [ + "^check(-.*)?$", + "^build(-.*)?$", + "^package(-.*)?$", + "^test(-.*)?$", + "^release(-.*)?$", + "^publish(-.*)?$", + ] + registries: [ + ci.providers.aws.registry, + ] + providers: { + aws: { + region: "eu-central-1" + registry: "332405224602.dkr.ecr.eu-central-1.amazonaws.com" + role: "arn:aws:iam::332405224602:role/ci" + } + + docker: credentials: { + provider: "aws" + path: "global/ci/docker" + } + + earthly: { + credentials: { + provider: "aws" + path: "global/ci/earthly" + } + org: "Catalyst" + satellite: "ci" + version: "0.8.15" + } + + github: registry: "ghcr.io" + } + secrets: [ + { + name: "GITHUB_TOKEN" + optional: true + provider: "env" + path: "GITHUB_TOKEN" + }, + ] + tagging: { + strategy: "commit" + } + } +} diff --git a/catalyst-gateway/Earthfile b/catalyst-gateway/Earthfile index 0795a93f6f..8ec606226d 100644 --- a/catalyst-gateway/Earthfile +++ b/catalyst-gateway/Earthfile @@ -1,6 +1,6 @@ VERSION 0.8 -IMPORT github.com/input-output-hk/catalyst-ci/earthly/rust:v3.2.03 AS rust-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/rust:v3.2.07 AS rust-ci #cspell: words rustfmt toolsets USERARCH stdcfgs @@ -62,7 +62,7 @@ all-hosts-build: package-cat-gateway: ARG tag="latest" - FROM alpine:3.19 + FROM alpine:3.20.3 WORKDIR /cat-gateway RUN apk add --no-cache gcc bash diff --git a/catalyst-gateway/Justfile b/catalyst-gateway/Justfile index a07d9b0c0e..724c79f5bc 100644 --- a/catalyst-gateway/Justfile +++ b/catalyst-gateway/Justfile @@ -44,12 +44,12 @@ build-cat-gateway: code-format code-lint # Run cat-gateway natively on preprod run-cat-gateway: build-cat-gateway CHAIN_FOLLOWER_SYNC_TASKS="16" \ - RUST_LOG="error,cat-gateway=debug,cardano_chain_follower=debug,mithril-client=debug" \ + RUST_LOG="error,cat_gateway=debug,cardano_chain_follower=debug,mithril-client=debug" \ CHAIN_NETWORK="Preprod" \ ./target/release/cat-gateway run --log-level debug # Run cat-gateway natively on mainnet run-cat-gateway-mainnet: build-cat-gateway CHAIN_FOLLOWER_SYNC_TASKS="1" \ - RUST_LOG="error,cat-gateway=debug,cardano_chain_follower=debug,mithril-client=debug" \ + RUST_LOG="error,cat_gateway=debug,cardano_chain_follower=debug,mithril-client=debug" \ ./target/release/cat-gateway run --log-level debug diff --git a/catalyst-gateway/bin/src/cardano/mod.rs b/catalyst-gateway/bin/src/cardano/mod.rs index 08e1c9015b..60ac5ae2ab 100644 --- a/catalyst-gateway/bin/src/cardano/mod.rs +++ b/catalyst-gateway/bin/src/cardano/mod.rs @@ -12,7 +12,7 @@ use tracing::{error, info, warn}; use crate::{ db::index::{block::index_block, session::CassandraSession}, - settings::Settings, + settings::{chain_follower, Settings}, }; // pub(crate) mod cip36_registration_obsolete; @@ -26,9 +26,13 @@ const MAX_BLOCKS_BATCH_LEN: usize = 1024; const INDEXING_DB_READY_WAIT_INTERVAL: Duration = Duration::from_secs(1); /// Start syncing a particular network -async fn start_sync_for(chain: Network) -> anyhow::Result<()> { - let cfg = ChainSyncConfig::default_for(chain); - info!(chain = %cfg.chain, "Starting Blockchain Sync"); +async fn start_sync_for(cfg: &chain_follower::EnvVars) -> anyhow::Result<()> { + let chain = cfg.chain; + let dl_config = cfg.dl_config.clone(); + + let mut cfg = ChainSyncConfig::default_for(chain); + cfg.mithril_cfg = cfg.mithril_cfg.with_dl_config(dl_config); + info!(chain = %chain, "Starting Blockchain Sync"); if let Err(error) = cfg.run().await { error!(chain=%chain, error=%error, "Failed to start chain sync task"); @@ -275,7 +279,7 @@ pub(crate) async fn start_followers() -> anyhow::Result<()> { cfg.log(); // Start Syncing the blockchain, so we can consume its data as required. - start_sync_for(cfg.chain).await?; + start_sync_for(&cfg).await?; info!(chain=%cfg.chain,"Chain Sync is started."); tokio::spawn(async move { diff --git a/catalyst-gateway/bin/src/db/index/block/certs.rs b/catalyst-gateway/bin/src/db/index/block/certs.rs index 86240df12b..1a8ecbf570 100644 --- a/catalyst-gateway/bin/src/db/index/block/certs.rs +++ b/catalyst-gateway/bin/src/db/index/block/certs.rs @@ -13,7 +13,7 @@ use crate::{ session::CassandraSession, }, service::utilities::convert::from_saturating, - settings::CassandraEnvVars, + settings::cassandra_db, }; /// Insert TXI Query and Parameters @@ -109,7 +109,7 @@ impl StakeRegistrationInsertQuery { /// Prepare Batch of Insert TXI Index Data Queries pub(crate) async fn prepare_batch( - session: &Arc, cfg: &CassandraEnvVars, + session: &Arc, cfg: &cassandra_db::EnvVars, ) -> anyhow::Result { let insert_queries = PreparedQueries::prepare_batch( session.clone(), @@ -145,7 +145,7 @@ impl CertInsertQuery { /// Prepare Batch of Insert TXI Index Data Queries pub(crate) async fn prepare_batch( - session: &Arc, cfg: &CassandraEnvVars, + session: &Arc, cfg: &cassandra_db::EnvVars, ) -> anyhow::Result { // Note: for now we have one query, but there are many certs, and later we may have more // to add here. diff --git a/catalyst-gateway/bin/src/db/index/block/cip36/insert_cip36.rs b/catalyst-gateway/bin/src/db/index/block/cip36/insert_cip36.rs index 771cb9b5d2..d0a94fe7d5 100644 --- a/catalyst-gateway/bin/src/db/index/block/cip36/insert_cip36.rs +++ b/catalyst-gateway/bin/src/db/index/block/cip36/insert_cip36.rs @@ -8,7 +8,7 @@ use tracing::error; use crate::{ db::index::queries::{PreparedQueries, SizedBatch}, - settings::CassandraEnvVars, + settings::cassandra_db, }; /// Index Registration by Stake Address @@ -82,7 +82,7 @@ impl Params { /// Prepare Batch of Insert CIP-36 Registration Index Data Queries pub(super) async fn prepare_batch( - session: &Arc, cfg: &CassandraEnvVars, + session: &Arc, cfg: &cassandra_db::EnvVars, ) -> anyhow::Result { let insert_queries = PreparedQueries::prepare_batch( session.clone(), diff --git a/catalyst-gateway/bin/src/db/index/block/cip36/insert_cip36_for_vote_key.rs b/catalyst-gateway/bin/src/db/index/block/cip36/insert_cip36_for_vote_key.rs index b7f0d48d83..e990443e30 100644 --- a/catalyst-gateway/bin/src/db/index/block/cip36/insert_cip36_for_vote_key.rs +++ b/catalyst-gateway/bin/src/db/index/block/cip36/insert_cip36_for_vote_key.rs @@ -8,7 +8,7 @@ use tracing::error; use crate::{ db::index::queries::{PreparedQueries, SizedBatch}, - settings::CassandraEnvVars, + settings::cassandra_db, }; /// Index Registration by Vote Key @@ -49,7 +49,7 @@ impl Params { /// Prepare Batch of Insert CIP-36 Registration Index Data Queries pub(super) async fn prepare_batch( - session: &Arc, cfg: &CassandraEnvVars, + session: &Arc, cfg: &cassandra_db::EnvVars, ) -> anyhow::Result { let insert_queries = PreparedQueries::prepare_batch( session.clone(), diff --git a/catalyst-gateway/bin/src/db/index/block/cip36/insert_cip36_invalid.rs b/catalyst-gateway/bin/src/db/index/block/cip36/insert_cip36_invalid.rs index 0ab3fd8122..a5a82e1d26 100644 --- a/catalyst-gateway/bin/src/db/index/block/cip36/insert_cip36_invalid.rs +++ b/catalyst-gateway/bin/src/db/index/block/cip36/insert_cip36_invalid.rs @@ -8,7 +8,7 @@ use tracing::error; use crate::{ db::index::queries::{PreparedQueries, SizedBatch}, - settings::CassandraEnvVars, + settings::cassandra_db, }; /// Index Registration by Stake Address (Invalid Registrations) @@ -99,7 +99,7 @@ impl Params { /// Prepare Batch of Insert CIP-36 Registration Index Data Queries pub(super) async fn prepare_batch( - session: &Arc, cfg: &CassandraEnvVars, + session: &Arc, cfg: &cassandra_db::EnvVars, ) -> anyhow::Result { let insert_queries = PreparedQueries::prepare_batch( session.clone(), diff --git a/catalyst-gateway/bin/src/db/index/block/cip36/mod.rs b/catalyst-gateway/bin/src/db/index/block/cip36/mod.rs index 13d4c70b46..a03dca54e2 100644 --- a/catalyst-gateway/bin/src/db/index/block/cip36/mod.rs +++ b/catalyst-gateway/bin/src/db/index/block/cip36/mod.rs @@ -14,7 +14,7 @@ use crate::{ queries::{FallibleQueryTasks, PreparedQuery, SizedBatch}, session::CassandraSession, }, - settings::CassandraEnvVars, + settings::cassandra_db, }; /// Insert CIP-36 Registration Queries @@ -39,7 +39,7 @@ impl Cip36InsertQuery { /// Prepare Batch of Insert Cip36 Registration Data Queries pub(crate) async fn prepare_batch( - session: &Arc, cfg: &CassandraEnvVars, + session: &Arc, cfg: &cassandra_db::EnvVars, ) -> anyhow::Result<(SizedBatch, SizedBatch, SizedBatch)> { let insert_cip36_batch = insert_cip36::Params::prepare_batch(session, cfg).await; let insert_cip36_invalid_batch = diff --git a/catalyst-gateway/bin/src/db/index/block/mod.rs b/catalyst-gateway/bin/src/db/index/block/mod.rs index 775b55d502..71969ef913 100644 --- a/catalyst-gateway/bin/src/db/index/block/mod.rs +++ b/catalyst-gateway/bin/src/db/index/block/mod.rs @@ -9,7 +9,7 @@ pub(crate) mod txo; use cardano_chain_follower::MultiEraBlock; use certs::CertInsertQuery; use cip36::Cip36InsertQuery; -use tracing::{debug, error}; +use tracing::error; use txi::TxiInsertQuery; use txo::TxoInsertQuery; @@ -75,7 +75,7 @@ pub(crate) async fn index_block(block: &MultiEraBlock) -> anyhow::Result<()> { match handle.await { Ok(join_res) => { match join_res { - Ok(res) => debug!(res=?res,"Query OK"), + Ok(_res) => {}, // debug!(res=?res,"Query OK") Err(error) => { // IF a query fails, assume everything else is broken. error!(error=%error,"Query Failed"); diff --git a/catalyst-gateway/bin/src/db/index/block/txi.rs b/catalyst-gateway/bin/src/db/index/block/txi.rs index 9dd4e0c8f9..f8ff02da8a 100644 --- a/catalyst-gateway/bin/src/db/index/block/txi.rs +++ b/catalyst-gateway/bin/src/db/index/block/txi.rs @@ -10,7 +10,7 @@ use crate::{ queries::{FallibleQueryTasks, PreparedQueries, PreparedQuery, SizedBatch}, session::CassandraSession, }, - settings::CassandraEnvVars, + settings::cassandra_db, }; /// Insert TXI Query and Parameters @@ -54,7 +54,7 @@ impl TxiInsertQuery { /// Prepare Batch of Insert TXI Index Data Queries pub(crate) async fn prepare_batch( - session: &Arc, cfg: &CassandraEnvVars, + session: &Arc, cfg: &cassandra_db::EnvVars, ) -> anyhow::Result { let txi_insert_queries = PreparedQueries::prepare_batch( session.clone(), diff --git a/catalyst-gateway/bin/src/db/index/block/txo/insert_txo.rs b/catalyst-gateway/bin/src/db/index/block/txo/insert_txo.rs index 94837b5093..d6c8b7702c 100644 --- a/catalyst-gateway/bin/src/db/index/block/txo/insert_txo.rs +++ b/catalyst-gateway/bin/src/db/index/block/txo/insert_txo.rs @@ -9,7 +9,7 @@ use tracing::error; use crate::{ db::index::queries::{PreparedQueries, SizedBatch}, - settings::CassandraEnvVars, + settings::cassandra_db, }; /// TXO by Stake Address Indexing query @@ -54,7 +54,7 @@ impl Params { /// Prepare Batch of Staked Insert TXO Asset Index Data Queries pub(super) async fn prepare_batch( - session: &Arc, cfg: &CassandraEnvVars, + session: &Arc, cfg: &cassandra_db::EnvVars, ) -> anyhow::Result { let txo_insert_queries = PreparedQueries::prepare_batch( session.clone(), 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 a42ea5b61e..ba7bbde7c4 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 @@ -7,7 +7,7 @@ use tracing::error; use crate::{ db::index::queries::{PreparedQueries, SizedBatch}, - settings::CassandraEnvVars, + settings::cassandra_db, }; /// TXO Asset by Stake Address Indexing Query @@ -56,7 +56,7 @@ impl Params { /// Prepare Batch of Staked Insert TXO Asset Index Data Queries pub(super) async fn prepare_batch( - session: &Arc, cfg: &CassandraEnvVars, + session: &Arc, cfg: &cassandra_db::EnvVars, ) -> anyhow::Result { let txo_insert_queries = PreparedQueries::prepare_batch( session.clone(), diff --git a/catalyst-gateway/bin/src/db/index/block/txo/insert_unstaked_txo.rs b/catalyst-gateway/bin/src/db/index/block/txo/insert_unstaked_txo.rs index 24957e92b3..7f68823af2 100644 --- a/catalyst-gateway/bin/src/db/index/block/txo/insert_unstaked_txo.rs +++ b/catalyst-gateway/bin/src/db/index/block/txo/insert_unstaked_txo.rs @@ -6,7 +6,7 @@ use tracing::error; use crate::{ db::index::queries::{PreparedQueries, SizedBatch}, - settings::CassandraEnvVars, + settings::cassandra_db, }; /// Unstaked TXO by Stake Address Indexing query @@ -47,7 +47,7 @@ impl Params { /// Prepare Batch of Staked Insert TXO Asset Index Data Queries pub(super) async fn prepare_batch( - session: &Arc, cfg: &CassandraEnvVars, + session: &Arc, cfg: &cassandra_db::EnvVars, ) -> anyhow::Result { let txo_insert_queries = PreparedQueries::prepare_batch( session.clone(), 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 78605f92ae..250ca8ae1c 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 @@ -7,7 +7,7 @@ use tracing::error; use crate::{ db::index::queries::{PreparedQueries, SizedBatch}, - settings::CassandraEnvVars, + settings::cassandra_db, }; /// Unstaked TXO Asset by Stake Address Indexing Query @@ -56,7 +56,7 @@ impl Params { /// Prepare Batch of Staked Insert TXO Asset Index Data Queries pub(super) async fn prepare_batch( - session: &Arc, cfg: &CassandraEnvVars, + session: &Arc, cfg: &cassandra_db::EnvVars, ) -> anyhow::Result { let txo_insert_queries = PreparedQueries::prepare_batch( session.clone(), 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 9b4029fc3b..66bd950822 100644 --- a/catalyst-gateway/bin/src/db/index/block/txo/mod.rs +++ b/catalyst-gateway/bin/src/db/index/block/txo/mod.rs @@ -18,7 +18,7 @@ use crate::{ session::CassandraSession, }, service::utilities::convert::from_saturating, - settings::CassandraEnvVars, + settings::cassandra_db, }; /// This is used to indicate that there is no stake address. @@ -52,7 +52,7 @@ impl TxoInsertQuery { /// Prepare Batch of Insert TXI Index Data Queries pub(crate) async fn prepare_batch( - session: &Arc, cfg: &CassandraEnvVars, + session: &Arc, cfg: &cassandra_db::EnvVars, ) -> anyhow::Result<(SizedBatch, SizedBatch, SizedBatch, SizedBatch)> { let txo_staked_insert_batch = insert_txo::Params::prepare_batch(session, cfg).await; let txo_unstaked_insert_batch = diff --git a/catalyst-gateway/bin/src/db/index/queries/mod.rs b/catalyst-gateway/bin/src/db/index/queries/mod.rs index 505918b58f..e34db0647e 100644 --- a/catalyst-gateway/bin/src/db/index/queries/mod.rs +++ b/catalyst-gateway/bin/src/db/index/queries/mod.rs @@ -20,7 +20,7 @@ use staked_ada::{ use super::block::{ certs::CertInsertQuery, cip36::Cip36InsertQuery, txi::TxiInsertQuery, txo::TxoInsertQuery, }; -use crate::settings::{CassandraEnvVars, CASSANDRA_MIN_BATCH_SIZE}; +use crate::settings::cassandra_db; /// Batches of different sizes, prepared and ready for use. pub(crate) type SizedBatch = SkipMap>; @@ -98,7 +98,9 @@ pub(crate) type FallibleQueryTasks = Vec, cfg: &CassandraEnvVars) -> anyhow::Result { + pub(crate) async fn new( + session: Arc, cfg: &cassandra_db::EnvVars, + ) -> anyhow::Result { // We initialize like this, so that all errors preparing querys get shown before aborting. let txi_insert_queries = TxiInsertQuery::prepare_batch(&session, cfg).await; let all_txo_queries = TxoInsertQuery::prepare_batch(&session, cfg).await; @@ -154,7 +156,7 @@ impl PreparedQueries { /// It is necessary to do this because batches are pre-sized, they can not be dynamic. /// Preparing the batches in advance is a very larger performance increase. pub(crate) async fn prepare_batch( - session: Arc, query: &str, cfg: &CassandraEnvVars, + session: Arc, query: &str, cfg: &cassandra_db::EnvVars, consistency: scylla::statement::Consistency, idempotent: bool, logged: bool, ) -> anyhow::Result { let sized_batches: SizedBatch = SkipMap::new(); @@ -163,7 +165,7 @@ impl PreparedQueries { // same. let prepared = Self::prepare(session, query, consistency, idempotent).await?; - for batch_size in CASSANDRA_MIN_BATCH_SIZE..=cfg.max_batch_size { + for batch_size in cassandra_db::MIN_BATCH_SIZE..=cfg.max_batch_size { let mut batch: Batch = Batch::new(if logged { scylla::batch::BatchType::Logged } else { @@ -171,7 +173,7 @@ impl PreparedQueries { }); batch.set_consistency(consistency); batch.set_is_idempotent(idempotent); - for _ in CASSANDRA_MIN_BATCH_SIZE..=batch_size { + for _ in cassandra_db::MIN_BATCH_SIZE..=batch_size { batch.append_statement(prepared.clone()); } @@ -208,7 +210,7 @@ impl PreparedQueries { /// This will divide the batch into optimal sized chunks and execute them until all /// values have been executed or the first error is encountered. pub(crate) async fn execute_batch( - &self, session: Arc, cfg: Arc, query: PreparedQuery, + &self, session: Arc, cfg: Arc, query: PreparedQuery, values: Vec, ) -> FallibleQueryResults { let query_map = match query { diff --git a/catalyst-gateway/bin/src/db/index/queries/staked_ada/update_txo_spent.rs b/catalyst-gateway/bin/src/db/index/queries/staked_ada/update_txo_spent.rs index 0fe0a60bcf..3c99869030 100644 --- a/catalyst-gateway/bin/src/db/index/queries/staked_ada/update_txo_spent.rs +++ b/catalyst-gateway/bin/src/db/index/queries/staked_ada/update_txo_spent.rs @@ -10,7 +10,7 @@ use crate::{ queries::{FallibleQueryResults, PreparedQueries, PreparedQuery, SizedBatch}, session::CassandraSession, }, - settings::CassandraEnvVars, + settings::cassandra_db, }; /// Update TXO spent query string. @@ -37,7 +37,7 @@ pub(crate) struct UpdateTxoSpentQuery; impl UpdateTxoSpentQuery { /// Prepare a batch of update TXO spent queries. pub(crate) async fn prepare_batch( - session: Arc, cfg: &CassandraEnvVars, + session: Arc, cfg: &cassandra_db::EnvVars, ) -> anyhow::Result { let update_txo_spent_queries = PreparedQueries::prepare_batch( session.clone(), diff --git a/catalyst-gateway/bin/src/db/index/schema/mod.rs b/catalyst-gateway/bin/src/db/index/schema/mod.rs index 4bfd4725db..89cba358da 100644 --- a/catalyst-gateway/bin/src/db/index/schema/mod.rs +++ b/catalyst-gateway/bin/src/db/index/schema/mod.rs @@ -8,7 +8,7 @@ use scylla::Session; use serde_json::json; use tracing::error; -use crate::settings::CassandraEnvVars; +use crate::settings::cassandra_db; /// Keyspace Create (Templated) const CREATE_NAMESPACE_CQL: &str = include_str!("./cql/namespace.cql"); @@ -67,7 +67,7 @@ const SCHEMAS: &[(&str, &str)] = &[ ]; /// Get the namespace for a particular db configuration -pub(crate) fn namespace(cfg: &CassandraEnvVars) -> String { +pub(crate) fn namespace(cfg: &cassandra_db::EnvVars) -> String { // Build and set the Keyspace to use. format!("{}_V{}", cfg.namespace.as_str(), SCHEMA_VERSION) } @@ -75,7 +75,7 @@ pub(crate) fn namespace(cfg: &CassandraEnvVars) -> String { /// Create the namespace we will use for this session /// Ok to run this if the namespace already exists. async fn create_namespace( - session: &mut Arc, cfg: &CassandraEnvVars, + session: &mut Arc, cfg: &cassandra_db::EnvVars, ) -> anyhow::Result<()> { let keyspace = namespace(cfg); @@ -102,7 +102,7 @@ async fn create_namespace( /// Create the Schema on the connected Cassandra DB pub(crate) async fn create_schema( - session: &mut Arc, cfg: &CassandraEnvVars, + session: &mut Arc, cfg: &cassandra_db::EnvVars, ) -> anyhow::Result<()> { create_namespace(session, cfg).await?; diff --git a/catalyst-gateway/bin/src/db/index/session.rs b/catalyst-gateway/bin/src/db/index/session.rs index 300bc9d92a..41c447634c 100644 --- a/catalyst-gateway/bin/src/db/index/session.rs +++ b/catalyst-gateway/bin/src/db/index/session.rs @@ -21,7 +21,7 @@ use super::{ }; use crate::{ db::index::queries, - settings::{CassandraEnvVars, Settings}, + settings::{cassandra_db, Settings}, }; /// Configuration Choices for compression @@ -55,7 +55,7 @@ pub(crate) struct CassandraSession { #[allow(dead_code)] persistent: bool, /// Configuration for this session. - cfg: Arc, + cfg: Arc, /// The actual session. session: Arc, /// All prepared queries we can use on this session. @@ -138,7 +138,7 @@ impl CassandraSession { /// /// The intention here is that we should be able to tune this based on configuration, /// but for now we don't so the `cfg` is not used yet. -fn make_execution_profile(_cfg: &CassandraEnvVars) -> ExecutionProfile { +fn make_execution_profile(_cfg: &cassandra_db::EnvVars) -> ExecutionProfile { ExecutionProfile::builder() .consistency(scylla::statement::Consistency::LocalQuorum) .serial_consistency(Some(scylla::statement::SerialConsistency::LocalSerial)) @@ -158,7 +158,7 @@ fn make_execution_profile(_cfg: &CassandraEnvVars) -> ExecutionProfile { } /// Construct a session based on the given configuration. -async fn make_session(cfg: &CassandraEnvVars) -> anyhow::Result> { +async fn make_session(cfg: &cassandra_db::EnvVars) -> anyhow::Result> { let cluster_urls: Vec<&str> = cfg.url.as_str().split(',').collect(); let mut sb = SessionBuilder::new() @@ -208,7 +208,7 @@ async fn make_session(cfg: &CassandraEnvVars) -> anyhow::Result> { /// Continuously try and init the DB, if it fails, backoff. /// /// Display reasonable logs to help diagnose DB connection issues. -async fn retry_init(cfg: CassandraEnvVars, persistent: bool) { +async fn retry_init(cfg: cassandra_db::EnvVars, persistent: bool) { let mut retry_delay = Duration::from_secs(0); let db_type = if persistent { "Persistent" } else { "Volatile" }; diff --git a/catalyst-gateway/bin/src/settings/cassandra_db.rs b/catalyst-gateway/bin/src/settings/cassandra_db.rs new file mode 100644 index 0000000000..6ce6e220a2 --- /dev/null +++ b/catalyst-gateway/bin/src/settings/cassandra_db.rs @@ -0,0 +1,121 @@ +//! Command line and environment variable settings for the service + +use tracing::info; + +use super::str_env_var::StringEnvVar; +use crate::db::{ + self, + index::session::{CompressionChoice, TlsChoice}, +}; + +/// Default Cassandra DB URL for the Persistent DB. +pub(super) const PERSISTENT_URL_DEFAULT: &str = "127.0.0.1:9042"; + +/// Default Cassandra DB URL for the Persistent DB. +pub(super) const PERSISTENT_NAMESPACE_DEFAULT: &str = "persistent"; + +/// Default Cassandra DB URL for the Persistent DB. +pub(super) const VOLATILE_URL_DEFAULT: &str = "127.0.0.1:9042"; + +/// Default Cassandra DB URL for the Persistent DB. +pub(super) const VOLATILE_NAMESPACE_DEFAULT: &str = "volatile"; + +/// Default maximum batch size. +/// This comes from: +/// +/// Scylla may support larger batches for better performance. +/// Larger batches will incur more memory overhead to store the prepared batches. +const MAX_BATCH_SIZE_DEFAULT: i64 = 30; + +/// Minimum possible batch size. +pub(crate) const MIN_BATCH_SIZE: i64 = 1; + +/// Maximum possible batch size. +const MAX_BATCH_SIZE: i64 = 256; + +/// Configuration for an individual cassandra cluster. +#[derive(Clone)] +pub(crate) struct EnvVars { + /// The Address/s of the DB. + pub(crate) url: StringEnvVar, + + /// The Namespace of Cassandra DB. + pub(crate) namespace: StringEnvVar, + + /// The `UserName` to use for the Cassandra DB. + pub(crate) username: Option, + + /// The Password to use for the Cassandra DB.. + pub(crate) password: Option, + + /// Use TLS for the connection? + pub(crate) tls: TlsChoice, + + /// Use TLS for the connection? + pub(crate) tls_cert: Option, + + /// Compression to use. + pub(crate) compression: CompressionChoice, + + /// Maximum Configured Batch size. + pub(crate) max_batch_size: i64, +} + +impl EnvVars { + /// Create a config for a cassandra cluster, identified by a default namespace. + pub(super) fn new(url: &str, namespace: &str) -> Self { + let name = namespace.to_uppercase(); + + // We can actually change the namespace, but can't change the name used for env vars. + let namespace = StringEnvVar::new(&format!("CASSANDRA_{name}_NAMESPACE"), namespace.into()); + + let tls = + StringEnvVar::new_as_enum(&format!("CASSANDRA_{name}_TLS"), TlsChoice::Disabled, false); + let compression = StringEnvVar::new_as_enum( + &format!("CASSANDRA_{name}_COMPRESSION"), + CompressionChoice::Lz4, + false, + ); + + Self { + url: StringEnvVar::new(&format!("CASSANDRA_{name}_URL"), url.into()), + namespace, + username: StringEnvVar::new_optional(&format!("CASSANDRA_{name}_USERNAME"), false), + password: StringEnvVar::new_optional(&format!("CASSANDRA_{name}_PASSWORD"), true), + tls, + tls_cert: StringEnvVar::new_optional(&format!("CASSANDRA_{name}_TLS_CERT"), false), + compression, + max_batch_size: StringEnvVar::new_as( + &format!("CASSANDRA_{name}_BATCH_SIZE"), + MAX_BATCH_SIZE_DEFAULT, + MIN_BATCH_SIZE, + MAX_BATCH_SIZE, + ), + } + } + + /// Log the configuration of this Cassandra DB + pub(crate) fn log(&self, persistent: bool) { + let db_type = if persistent { "Persistent" } else { "Volatile" }; + + let auth = match (&self.username, &self.password) { + (Some(u), Some(_)) => format!("Username: {} Password: REDACTED", u.as_str()), + _ => "No Authentication".to_string(), + }; + + let tls_cert = match &self.tls_cert { + None => "No TLS Certificate Defined".to_string(), + Some(cert) => cert.as_string(), + }; + + info!( + url = self.url.as_str(), + namespace = db::index::schema::namespace(self), + auth = auth, + tls = self.tls.to_string(), + cert = tls_cert, + compression = self.compression.to_string(), + "Cassandra {db_type} DB Configuration" + ); + } +} diff --git a/catalyst-gateway/bin/src/settings/chain_follower.rs b/catalyst-gateway/bin/src/settings/chain_follower.rs new file mode 100644 index 0000000000..9311f71cbc --- /dev/null +++ b/catalyst-gateway/bin/src/settings/chain_follower.rs @@ -0,0 +1,135 @@ +//! Command line and environment variable settings for the service + +use std::{cmp::min, time::Duration}; + +use cardano_chain_follower::{turbo_downloader::DlConfig, ChainSyncConfig, Network}; +use tracing::info; + +use super::str_env_var::StringEnvVar; + +/// Default chain to follow. +const DEFAULT_NETWORK: Network = Network::Mainnet; + +/// Default number of sync tasks (must be in the range 1 to 256 inclusive.) +const DEFAULT_SYNC_TASKS: u16 = 16; + +/// Maximum number of sync tasks (must be in the range 1 to 256 inclusive.) +const MAX_SYNC_TASKS: u16 = 256; + +/// Maximum number of DL Connections (must be in the range 1 to 256 inclusive.) +const MAX_DL_CONNECTIONS: usize = 256; + +/// Maximum DL Chunk Size in MB (must be in the range 1 to 256 inclusive.) +const MAX_DL_CHUNK_SIZE: usize = 256; + +/// Maximum DL Chunk Queue Ahead (must be in the range 1 to 256 inclusive.) +const MAX_DL_CHUNK_QUEUE_AHEAD: usize = 256; + +/// Maximum DL Chunk Connect/Data Timeout in seconds (0 = Disabled). +const MAX_DL_TIMEOUT: u64 = 300; + +/// Number of bytes in a Megabyte +const ONE_MEGABYTE: usize = 1_048_576; + +/// Configuration for the chain follower. +#[derive(Clone)] +pub(crate) struct EnvVars { + /// The Blockchain we sync from. + pub(crate) chain: Network, + + /// The maximum number of sync tasks. + pub(crate) sync_tasks: u16, + + /// The Mithril Downloader Configuration. + pub(crate) dl_config: DlConfig, +} + +impl EnvVars { + /// Create a config for a cassandra cluster, identified by a default namespace. + pub(super) fn new() -> Self { + let chain = StringEnvVar::new_as_enum("CHAIN_NETWORK", DEFAULT_NETWORK, false); + let sync_tasks: u16 = StringEnvVar::new_as( + "CHAIN_FOLLOWER_SYNC_TASKS", + DEFAULT_SYNC_TASKS, + 1, + MAX_SYNC_TASKS, + ); + + let cfg = ChainSyncConfig::default_for(chain); + let mut dl_config = cfg.mithril_cfg.dl_config.clone().unwrap_or_default(); + + let workers = StringEnvVar::new_as( + "CHAIN_FOLLOWER_DL_CONNECTIONS", + dl_config.workers, + 1, + MAX_DL_CONNECTIONS, + ); + dl_config = dl_config.with_workers(workers); + + let default_dl_chunk_size = min(1, dl_config.chunk_size / ONE_MEGABYTE); + + let chunk_size = StringEnvVar::new_as( + "CHAIN_FOLLOWER_DL_CHUNK_SIZE", + default_dl_chunk_size, + 1, + MAX_DL_CHUNK_SIZE, + ) * ONE_MEGABYTE; + dl_config = dl_config.with_chunk_size(chunk_size); + + let queue_ahead = StringEnvVar::new_as( + "CHAIN_FOLLOWER_DL_QUEUE_AHEAD", + dl_config.queue_ahead, + 1, + MAX_DL_CHUNK_QUEUE_AHEAD, + ); + dl_config = dl_config.with_queue_ahead(queue_ahead); + + let default_dl_connect_timeout = match dl_config.connection_timeout { + Some(timeout) => timeout.as_secs(), + None => 0, + }; + let dl_connect_timeout = StringEnvVar::new_as( + "CHAIN_FOLLOWER_DL_CONNECT_TIMEOUT", + default_dl_connect_timeout, + 0, + MAX_DL_TIMEOUT, + ); + if dl_connect_timeout == 0 { + dl_config.connection_timeout = None; + } else { + dl_config = dl_config.with_connection_timeout(Duration::from_secs(dl_connect_timeout)); + } + + let default_dl_data_timeout = match dl_config.data_read_timeout { + Some(timeout) => timeout.as_secs(), + None => 0, + }; + let dl_data_timeout = StringEnvVar::new_as( + "CHAIN_FOLLOWER_DL_DATA_TIMEOUT", + default_dl_data_timeout, + 0, + MAX_DL_TIMEOUT, + ); + if dl_connect_timeout == 0 { + dl_config.data_read_timeout = None; + } else { + dl_config = dl_config.with_data_read_timeout(Duration::from_secs(dl_data_timeout)); + } + + Self { + chain, + sync_tasks, + dl_config, + } + } + + /// Log the configuration of this Chain Follower + pub(crate) fn log(&self) { + info!( + chain = self.chain.to_string(), + sync_tasks = self.sync_tasks, + dl_config = ?self.dl_config, + "Chain Follower Configuration" + ); + } +} diff --git a/catalyst-gateway/bin/src/settings.rs b/catalyst-gateway/bin/src/settings/mod.rs similarity index 52% rename from catalyst-gateway/bin/src/settings.rs rename to catalyst-gateway/bin/src/settings/mod.rs index b6d2d99b8f..fa96587296 100644 --- a/catalyst-gateway/bin/src/settings.rs +++ b/catalyst-gateway/bin/src/settings/mod.rs @@ -1,7 +1,5 @@ //! Command line and environment variable settings for the service use std::{ - env::{self, VarError}, - fmt::{self, Display}, net::{IpAddr, Ipv4Addr, SocketAddr}, path::PathBuf, str::FromStr, @@ -10,25 +8,24 @@ use std::{ }; use anyhow::anyhow; -use cardano_chain_follower::Network; use clap::Args; use cryptoxide::{blake2b::Blake2b, mac::Mac}; use dotenvy::dotenv; use duration_string::DurationString; -use strum::VariantNames; -use tracing::{error, info}; +use str_env_var::StringEnvVar; +use tracing::error; use url::Url; use crate::{ build_info::{log_build_info, BUILD_INFO}, - db::{ - self, - index::session::{CompressionChoice, TlsChoice}, - }, logger::{self, LogLevel, LOG_LEVEL_DEFAULT}, service::utilities::net::{get_public_ipv4, get_public_ipv6}, }; +pub(crate) mod cassandra_db; +pub(crate) mod chain_follower; +mod str_env_var; + /// Default address to start service on. const ADDRESS_DEFAULT: &str = "0.0.0.0:3030"; @@ -58,37 +55,6 @@ const CHECK_CONFIG_TICK_DEFAULT: &str = "5s"; const EVENT_DB_URL_DEFAULT: &str = "postgresql://postgres:postgres@localhost/catalyst_events?sslmode=disable"; -/// Default Cassandra DB URL for the Persistent DB. -const CASSANDRA_PERSISTENT_DB_URL_DEFAULT: &str = "127.0.0.1:9042"; - -/// Default Cassandra DB URL for the Persistent DB. -const CASSANDRA_PERSISTENT_DB_NAMESPACE_DEFAULT: &str = "persistent"; - -/// Default Cassandra DB URL for the Persistent DB. -const CASSANDRA_VOLATILE_DB_URL_DEFAULT: &str = "127.0.0.1:9042"; - -/// Default Cassandra DB URL for the Persistent DB. -const CASSANDRA_VOLATILE_DB_NAMESPACE_DEFAULT: &str = "volatile"; - -/// Default maximum batch size. -/// This comes from: -/// -/// Scylla may support larger batches for better performance. -/// Larger batches will incur more memory overhead to store the prepared batches. -const CASSANDRA_MAX_BATCH_SIZE_DEFAULT: i64 = 30; - -/// Minimum possible batch size. -pub(crate) const CASSANDRA_MIN_BATCH_SIZE: i64 = 1; - -/// Maximum possible batch size. -const CASSANDRA_MAX_BATCH_SIZE: i64 = 256; - -/// Default chain to follow. -const CHAIN_FOLLOWER_DEFAULT: Network = Network::Mainnet; - -/// Default number of sync tasks (must be in the range 1 to 255 inclusive.) -const CHAIN_FOLLOWER_SYNC_TASKS_DEFAULT: u16 = 16; - /// Hash the Public IPv4 and IPv6 address of the machine, and convert to a 128 bit V4 /// UUID. fn calculate_service_uuid() -> String { @@ -146,389 +112,6 @@ pub(crate) struct DocsSettings { pub(crate) server_name: Option, } -/// An environment variable read as a string. -#[derive(Clone)] -pub(crate) struct StringEnvVar { - /// Value of the env var. - value: String, - /// Whether the env var is displayed redacted or not. - redacted: bool, -} - -/// Ergonomic way of specifying if a env var needs to be redacted or not. -enum StringEnvVarParams { - /// The env var is plain and should not be redacted. - Plain(String, Option), - /// The env var is redacted and should be redacted. - Redacted(String, Option), -} - -impl From<&str> for StringEnvVarParams { - fn from(s: &str) -> Self { - StringEnvVarParams::Plain(String::from(s), None) - } -} - -impl From for StringEnvVarParams { - fn from(s: String) -> Self { - StringEnvVarParams::Plain(s, None) - } -} - -impl From<(&str, bool)> for StringEnvVarParams { - fn from((s, r): (&str, bool)) -> Self { - if r { - StringEnvVarParams::Redacted(String::from(s), None) - } else { - StringEnvVarParams::Plain(String::from(s), None) - } - } -} - -impl From<(&str, bool, &str)> for StringEnvVarParams { - fn from((s, r, c): (&str, bool, &str)) -> Self { - if r { - StringEnvVarParams::Redacted(String::from(s), Some(String::from(c))) - } else { - StringEnvVarParams::Plain(String::from(s), Some(String::from(c))) - } - } -} - -/// An environment variable read as a string. -impl StringEnvVar { - /// Read the env var from the environment. - /// - /// If not defined, read from a .env file. - /// If still not defined, use the default. - /// - /// # Arguments - /// - /// * `var_name`: &str - the name of the env var - /// * `default_value`: &str - the default value - /// - /// # Returns - /// - /// * Self - the value - /// - /// # Example - /// - /// ```rust,no_run - /// #use cat_data_service::settings::StringEnvVar; - /// - /// let var = StringEnvVar::new("MY_VAR", "default"); - /// assert_eq!(var.as_str(), "default"); - /// ``` - fn new(var_name: &str, param: StringEnvVarParams) -> Self { - let (default_value, redacted, choices) = match param { - StringEnvVarParams::Plain(s, c) => (s, false, c), - StringEnvVarParams::Redacted(s, c) => (s, true, c), - }; - - match env::var(var_name) { - Ok(value) => { - if redacted { - info!(env = var_name, value = "Redacted", "Env Var Defined"); - } else { - info!(env = var_name, value = value, "Env Var Defined"); - } - Self { value, redacted } - }, - Err(VarError::NotPresent) => { - if let Some(choices) = choices { - if redacted { - info!( - env = var_name, - default = "Default Redacted", - choices = choices, - "Env Var Defaulted" - ); - } else { - info!( - env = var_name, - default = default_value, - choices = choices, - "Env Var Defaulted" - ); - }; - } else if redacted { - info!( - env = var_name, - default = "Default Redacted", - "Env Var Defined" - ); - } else { - info!(env = var_name, default = default_value, "Env Var Defaulted"); - } - - Self { - value: default_value, - redacted, - } - }, - Err(error) => { - error!( - env = var_name, - default = default_value, - error = ?error, - "Env Var Error" - ); - Self { - value: default_value, - redacted, - } - }, - } - } - - /// New Env Var that is optional. - fn new_optional(var_name: &str, redacted: bool) -> Option { - match env::var(var_name) { - Ok(value) => { - if redacted { - info!(env = var_name, value = "Redacted", "Env Var Defined"); - } else { - info!(env = var_name, value = value, "Env Var Defined"); - } - Some(Self { value, redacted }) - }, - Err(VarError::NotPresent) => { - info!(env = var_name, "Env Var Not Set"); - None - }, - Err(error) => { - error!( - env = var_name, - error = ?error, - "Env Var Error" - ); - None - }, - } - } - - /// Convert an Envvar into the required Enum Type. - fn new_as_enum( - var_name: &str, default: T, redacted: bool, - ) -> T - where ::Err: std::fmt::Display { - let mut choices = String::new(); - for name in T::VARIANTS { - if choices.is_empty() { - choices.push('['); - } else { - choices.push(','); - } - choices.push_str(name); - } - choices.push(']'); - - let choice = StringEnvVar::new( - var_name, - (default.to_string().as_str(), redacted, choices.as_str()).into(), - ); - - let value = match T::from_str(choice.as_str()) { - Ok(var) => var, - Err(error) => { - error!(error=%error, default=%default, choices=choices, choice=%choice, "Invalid choice. Using Default."); - default - }, - }; - - value - } - - /// Convert an Envvar into an integer in the bounded range. - fn new_as_i64(var_name: &str, default: i64, min: i64, max: i64) -> i64 -where { - let choices = format!("A value in the range {min} to {max} inclusive"); - - let raw_value = StringEnvVar::new( - var_name, - (default.to_string().as_str(), false, choices.as_str()).into(), - ) - .as_string(); - - match raw_value.parse::() { - Ok(value) => { - if value < min { - error!("{var_name} out of range. Range = {min} to {max} inclusive. Clamped to {min}"); - min - } else if value > max { - error!("{var_name} out of range. Range = {min} to {max} inclusive. Clamped to {max}"); - max - } else { - value - } - }, - Err(error) => { - error!(error=%error, default=default, "{var_name} not an integer. Range = {min} to {max} inclusive. Defaulted"); - default - }, - } - } - - /// Get the read env var as a str. - /// - /// # Returns - /// - /// * &str - the value - pub(crate) fn as_str(&self) -> &str { - &self.value - } - - /// Get the read env var as a str. - /// - /// # Returns - /// - /// * &str - the value - pub(crate) fn as_string(&self) -> String { - self.value.clone() - } -} - -impl fmt::Display for StringEnvVar { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.redacted { - return write!(f, "REDACTED"); - } - write!(f, "{}", self.value) - } -} - -impl fmt::Debug for StringEnvVar { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.redacted { - return write!(f, "REDACTED"); - } - write!(f, "env: {}", self.value) - } -} - -/// Configuration for an individual cassandra cluster. -#[derive(Clone)] -pub(crate) struct CassandraEnvVars { - /// The Address/s of the DB. - pub(crate) url: StringEnvVar, - - /// The Namespace of Cassandra DB. - pub(crate) namespace: StringEnvVar, - - /// The `UserName` to use for the Cassandra DB. - pub(crate) username: Option, - - /// The Password to use for the Cassandra DB.. - pub(crate) password: Option, - - /// Use TLS for the connection? - pub(crate) tls: TlsChoice, - - /// Use TLS for the connection? - pub(crate) tls_cert: Option, - - /// Compression to use. - pub(crate) compression: CompressionChoice, - - /// Maximum Configured Batch size. - pub(crate) max_batch_size: i64, -} - -impl CassandraEnvVars { - /// Create a config for a cassandra cluster, identified by a default namespace. - fn new(url: &str, namespace: &str) -> Self { - let name = namespace.to_uppercase(); - - // We can actually change the namespace, but can't change the name used for env vars. - let namespace = StringEnvVar::new(&format!("CASSANDRA_{name}_NAMESPACE"), namespace.into()); - - let tls = - StringEnvVar::new_as_enum(&format!("CASSANDRA_{name}_TLS"), TlsChoice::Disabled, false); - let compression = StringEnvVar::new_as_enum( - &format!("CASSANDRA_{name}_COMPRESSION"), - CompressionChoice::Lz4, - false, - ); - - Self { - url: StringEnvVar::new(&format!("CASSANDRA_{name}_URL"), url.into()), - namespace, - username: StringEnvVar::new_optional(&format!("CASSANDRA_{name}_USERNAME"), false), - password: StringEnvVar::new_optional(&format!("CASSANDRA_{name}_PASSWORD"), true), - tls, - tls_cert: StringEnvVar::new_optional(&format!("CASSANDRA_{name}_TLS_CERT"), false), - compression, - max_batch_size: StringEnvVar::new_as_i64( - &format!("CASSANDRA_{name}_BATCH_SIZE"), - CASSANDRA_MAX_BATCH_SIZE_DEFAULT, - CASSANDRA_MIN_BATCH_SIZE, - CASSANDRA_MAX_BATCH_SIZE, - ), - } - } - - /// Log the configuration of this Cassandra DB - pub(crate) fn log(&self, persistent: bool) { - let db_type = if persistent { "Persistent" } else { "Volatile" }; - - let auth = match (&self.username, &self.password) { - (Some(u), Some(_)) => format!("Username: {} Password: REDACTED", u.as_str()), - _ => "No Authentication".to_string(), - }; - - let tls_cert = match &self.tls_cert { - None => "No TLS Certificate Defined".to_string(), - Some(cert) => cert.as_string(), - }; - - info!( - url = self.url.as_str(), - namespace = db::index::schema::namespace(self), - auth = auth, - tls = self.tls.to_string(), - cert = tls_cert, - compression = self.compression.to_string(), - "Cassandra {db_type} DB Configuration" - ); - } -} - -/// Configuration for the chain follower. -#[derive(Clone)] -pub(crate) struct ChainFollowerEnvVars { - /// The Blockchain we sync from. - pub(crate) chain: Network, - - /// The maximum number of sync tasks. - pub(crate) sync_tasks: u16, -} - -impl ChainFollowerEnvVars { - /// Create a config for a cassandra cluster, identified by a default namespace. - fn new() -> Self { - let chain = StringEnvVar::new_as_enum("CHAIN_NETWORK", CHAIN_FOLLOWER_DEFAULT, false); - let sync_tasks: u16 = StringEnvVar::new_as_i64( - "CHAIN_FOLLOWER_SYNC_TASKS", - CHAIN_FOLLOWER_SYNC_TASKS_DEFAULT.into(), - 1, - u16::MAX.into(), - ) - .try_into() - .unwrap_or(CHAIN_FOLLOWER_SYNC_TASKS_DEFAULT); - - Self { chain, sync_tasks } - } - - /// Log the configuration of this Chain Follower - pub(crate) fn log(&self) { - info!( - chain = self.chain.to_string(), - sync_tasks = self.sync_tasks, - "Chain Follower Configuration" - ); - } -} - /// All the `EnvVars` used by the service. struct EnvVars { /// The github repo owner @@ -562,13 +145,13 @@ struct EnvVars { event_db_password: Option, /// The Config of the Persistent Cassandra DB. - cassandra_persistent_db: CassandraEnvVars, + cassandra_persistent_db: cassandra_db::EnvVars, /// The Config of the Volatile Cassandra DB. - cassandra_volatile_db: CassandraEnvVars, + cassandra_volatile_db: cassandra_db::EnvVars, /// The Chain Follower configuration - chain_follower: ChainFollowerEnvVars, + chain_follower: chain_follower::EnvVars, /// Tick every N seconds until config exists in db #[allow(unused)] @@ -613,15 +196,15 @@ static ENV_VARS: LazyLock = LazyLock::new(|| { event_db_url: StringEnvVar::new("EVENT_DB_URL", EVENT_DB_URL_DEFAULT.into()), event_db_username: StringEnvVar::new_optional("EVENT_DB_USERNAME", false), event_db_password: StringEnvVar::new_optional("EVENT_DB_PASSWORD", true), - cassandra_persistent_db: CassandraEnvVars::new( - CASSANDRA_PERSISTENT_DB_URL_DEFAULT, - CASSANDRA_PERSISTENT_DB_NAMESPACE_DEFAULT, + cassandra_persistent_db: cassandra_db::EnvVars::new( + cassandra_db::PERSISTENT_URL_DEFAULT, + cassandra_db::PERSISTENT_NAMESPACE_DEFAULT, ), - cassandra_volatile_db: CassandraEnvVars::new( - CASSANDRA_VOLATILE_DB_URL_DEFAULT, - CASSANDRA_VOLATILE_DB_NAMESPACE_DEFAULT, + cassandra_volatile_db: cassandra_db::EnvVars::new( + cassandra_db::VOLATILE_URL_DEFAULT, + cassandra_db::VOLATILE_NAMESPACE_DEFAULT, ), - chain_follower: ChainFollowerEnvVars::new(), + chain_follower: chain_follower::EnvVars::new(), check_config_tick, } }); @@ -683,7 +266,7 @@ impl Settings { } /// Get the Persistent & Volatile Cassandra DB config for this service. - pub(crate) fn cassandra_db_cfg() -> (CassandraEnvVars, CassandraEnvVars) { + pub(crate) fn cassandra_db_cfg() -> (cassandra_db::EnvVars, cassandra_db::EnvVars) { ( ENV_VARS.cassandra_persistent_db.clone(), ENV_VARS.cassandra_volatile_db.clone(), @@ -691,7 +274,7 @@ impl Settings { } /// Get the configuration of the chain follower. - pub(crate) fn follower_cfg() -> ChainFollowerEnvVars { + pub(crate) fn follower_cfg() -> chain_follower::EnvVars { ENV_VARS.chain_follower.clone() } diff --git a/catalyst-gateway/bin/src/settings/str_env_var.rs b/catalyst-gateway/bin/src/settings/str_env_var.rs new file mode 100644 index 0000000000..18e8a6414b --- /dev/null +++ b/catalyst-gateway/bin/src/settings/str_env_var.rs @@ -0,0 +1,239 @@ +//! Processing for String Environment Variables +use std::{ + env::{self, VarError}, + fmt::{self, Display}, + str::FromStr, +}; + +use strum::VariantNames; +use tracing::{error, info}; + +/// An environment variable read as a string. +#[derive(Clone)] +pub(crate) struct StringEnvVar { + /// Value of the env var. + value: String, + /// Whether the env var is displayed redacted or not. + redacted: bool, +} + +/// Ergonomic way of specifying if a env var needs to be redacted or not. +pub(super) enum StringEnvVarParams { + /// The env var is plain and should not be redacted. + Plain(String, Option), + /// The env var is redacted and should be redacted. + Redacted(String, Option), +} + +impl From<&str> for StringEnvVarParams { + fn from(s: &str) -> Self { + StringEnvVarParams::Plain(String::from(s), None) + } +} + +impl From for StringEnvVarParams { + fn from(s: String) -> Self { + StringEnvVarParams::Plain(s, None) + } +} + +impl From<(&str, bool)> for StringEnvVarParams { + fn from((s, r): (&str, bool)) -> Self { + if r { + StringEnvVarParams::Redacted(String::from(s), None) + } else { + StringEnvVarParams::Plain(String::from(s), None) + } + } +} + +impl From<(&str, bool, &str)> for StringEnvVarParams { + fn from((s, r, c): (&str, bool, &str)) -> Self { + if r { + StringEnvVarParams::Redacted(String::from(s), Some(String::from(c))) + } else { + StringEnvVarParams::Plain(String::from(s), Some(String::from(c))) + } + } +} + +/// An environment variable read as a string. +impl StringEnvVar { + /// Read the env var from the environment. + /// + /// If not defined, read from a .env file. + /// If still not defined, use the default. + /// + /// # Arguments + /// + /// * `var_name`: &str - the name of the env var + /// * `default_value`: &str - the default value + /// + /// # Returns + /// + /// * Self - the value + /// + /// # Example + /// + /// ```rust,no_run + /// #use cat_data_service::settings::StringEnvVar; + /// + /// let var = StringEnvVar::new("MY_VAR", "default"); + /// assert_eq!(var.as_str(), "default"); + /// ``` + pub(super) fn new(var_name: &str, param: StringEnvVarParams) -> Self { + let (default_value, redacted, choices) = match param { + StringEnvVarParams::Plain(s, c) => (s, false, c), + StringEnvVarParams::Redacted(s, c) => (s, true, c), + }; + + match env::var(var_name) { + Ok(value) => { + let value = Self { value, redacted }; + info!(env=var_name, value=%value, "Env Var Defined"); + value + }, + Err(err) => { + let value = Self { + value: default_value, + redacted, + }; + if err == VarError::NotPresent { + if let Some(choices) = choices { + info!(env=var_name, default=%value, choices=choices, "Env Var Defaulted"); + } else { + info!(env=var_name, default=%value, "Env Var Defaulted"); + } + } else if let Some(choices) = choices { + info!(env=var_name, default=%value, choices=choices, error=?err, + "Env Var Error"); + } else { + info!(env=var_name, default=%value, error=?err, "Env Var Error"); + } + + value + }, + } + } + + /// New Env Var that is optional. + pub(super) fn new_optional(var_name: &str, redacted: bool) -> Option { + match env::var(var_name) { + Ok(value) => { + let value = Self { value, redacted }; + info!(env = var_name, value = %value, "Env Var Defined"); + Some(value) + }, + Err(VarError::NotPresent) => { + info!(env = var_name, "Env Var Not Set"); + None + }, + Err(error) => { + error!(env = var_name, error = ?error, "Env Var Error"); + None + }, + } + } + + /// Convert an Envvar into the required Enum Type. + pub(super) fn new_as_enum( + var_name: &str, default: T, redacted: bool, + ) -> T + where ::Err: std::fmt::Display { + let mut choices = String::new(); + for name in T::VARIANTS { + if choices.is_empty() { + choices.push('['); + } else { + choices.push(','); + } + choices.push_str(name); + } + choices.push(']'); + + let choice = StringEnvVar::new( + var_name, + (default.to_string().as_str(), redacted, choices.as_str()).into(), + ); + + let value = match T::from_str(choice.as_str()) { + Ok(var) => var, + Err(error) => { + error!(error=%error, default=%default, choices=choices, choice=%choice, + "Invalid choice. Using Default."); + default + }, + }; + + value + } + + /// Convert an Envvar into an integer in the bounded range. + pub(super) fn new_as(var_name: &str, default: T, min: T, max: T) -> T + where + T: FromStr + Display + PartialOrd + tracing::Value, + ::Err: std::fmt::Display, + { + let choices = format!("A value in the range {min} to {max} inclusive"); + + let raw_value = StringEnvVar::new( + var_name, + (default.to_string().as_str(), false, choices.as_str()).into(), + ) + .as_string(); + + match raw_value.parse::() { + Ok(value) => { + if value < min { + error!("{var_name} out of range. Range = {min} to {max} inclusive. Clamped to {min}"); + min + } else if value > max { + error!("{var_name} out of range. Range = {min} to {max} inclusive. Clamped to {max}"); + max + } else { + value + } + }, + Err(error) => { + error!(error=%error, default=default, "{var_name} not an integer. Range = {min} to {max} inclusive. Defaulted"); + default + }, + } + } + + /// Get the read env var as a str. + /// + /// # Returns + /// + /// * &str - the value + pub(crate) fn as_str(&self) -> &str { + &self.value + } + + /// Get the read env var as a str. + /// + /// # Returns + /// + /// * &str - the value + pub(crate) fn as_string(&self) -> String { + self.value.clone() + } +} + +impl fmt::Display for StringEnvVar { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.redacted { + return write!(f, "REDACTED"); + } + write!(f, "{}", self.value) + } +} + +impl fmt::Debug for StringEnvVar { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.redacted { + return write!(f, "REDACTED"); + } + write!(f, "env: {}", self.value) + } +} diff --git a/catalyst-gateway/blueprint.cue b/catalyst-gateway/blueprint.cue new file mode 100644 index 0000000000..3278906156 --- /dev/null +++ b/catalyst-gateway/blueprint.cue @@ -0,0 +1,2 @@ +version: "1.0.0" +project: name: "gateway" diff --git a/catalyst-gateway/event-db/Earthfile b/catalyst-gateway/event-db/Earthfile index 823d60a0c8..79c7917b10 100644 --- a/catalyst-gateway/event-db/Earthfile +++ b/catalyst-gateway/event-db/Earthfile @@ -3,7 +3,7 @@ # the database and its associated software. VERSION 0.8 -IMPORT github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.2.03 AS postgresql-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.2.07 AS postgresql-ci # cspell: words diff --git a/catalyst-gateway/event-db/blueprint.cue b/catalyst-gateway/event-db/blueprint.cue new file mode 100644 index 0000000000..1dad7a98b0 --- /dev/null +++ b/catalyst-gateway/event-db/blueprint.cue @@ -0,0 +1,2 @@ +version: "1.0.0" +project: name: "gateway-event-db" diff --git a/catalyst-gateway/tests/Earthfile b/catalyst-gateway/tests/Earthfile index 41d344b024..d3e0b7df81 100644 --- a/catalyst-gateway/tests/Earthfile +++ b/catalyst-gateway/tests/Earthfile @@ -1,5 +1,5 @@ VERSION 0.8 -IMPORT github.com/input-output-hk/catalyst-ci/earthly/spectral:v3.2.03 AS spectral-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/spectral:v3.2.07 AS spectral-ci # test-lint-openapi - OpenAPI linting from an artifact # testing whether the OpenAPI generated during build stage follows good practice. diff --git a/catalyst-gateway/tests/api_tests/Earthfile b/catalyst-gateway/tests/api_tests/Earthfile index 0aac1a2e78..3ebea8e0f8 100644 --- a/catalyst-gateway/tests/api_tests/Earthfile +++ b/catalyst-gateway/tests/api_tests/Earthfile @@ -1,6 +1,6 @@ VERSION 0.8 -IMPORT github.com/input-output-hk/catalyst-ci/earthly/python:v3.2.03 AS python-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/python:v3.2.07 AS python-ci builder: FROM python-ci+python-base diff --git a/catalyst-gateway/tests/api_tests/blueprint.cue b/catalyst-gateway/tests/api_tests/blueprint.cue new file mode 100644 index 0000000000..d15bf941d5 --- /dev/null +++ b/catalyst-gateway/tests/api_tests/blueprint.cue @@ -0,0 +1,2 @@ +version: "1.0.0" +project: name: "gateway-api-tests" diff --git a/catalyst-gateway/tests/blueprint.cue b/catalyst-gateway/tests/blueprint.cue new file mode 100644 index 0000000000..17b8489385 --- /dev/null +++ b/catalyst-gateway/tests/blueprint.cue @@ -0,0 +1,2 @@ +version: "1.0.0" +project: name: "catalyst-gateway-tests" diff --git a/catalyst-gateway/tests/schemathesis_tests/Earthfile b/catalyst-gateway/tests/schemathesis_tests/Earthfile index ded02e5a58..a10bd3e110 100644 --- a/catalyst-gateway/tests/schemathesis_tests/Earthfile +++ b/catalyst-gateway/tests/schemathesis_tests/Earthfile @@ -1,7 +1,7 @@ VERSION 0.8 package-schemathesis: - FROM python:3.12-alpine3.19 + FROM python:3.12-alpine3.20 # TODO: https://github.com/input-output-hk/catalyst-voices/issues/465 RUN apk add --no-cache gcc musl-dev RUN python -m pip install schemathesis==3.27.1 @@ -29,7 +29,7 @@ package-schemathesis: # test-fuzzer-api - Fuzzy test cat-gateway using openapi specs. # Disabled in CI, to enable it rename to `test-fuzzer-api`. fuzzer-api: - FROM earthly/dind:alpine-3.19 + FROM earthly/dind:alpine-alpine-3.20-docker-26.1.5-r0 RUN apk update && apk add iptables-legacy # workaround for https://github.com/earthly/earthly/issues/3784 RUN apk add yq zstd COPY schemathesis-docker-compose.yml . diff --git a/catalyst-gateway/tests/schemathesis_tests/blueprint.cue b/catalyst-gateway/tests/schemathesis_tests/blueprint.cue new file mode 100644 index 0000000000..4a26b5160d --- /dev/null +++ b/catalyst-gateway/tests/schemathesis_tests/blueprint.cue @@ -0,0 +1,2 @@ +version: "1.0.0" +project: name: "gateway-schema-tests" diff --git a/catalyst_voices/Earthfile b/catalyst_voices/Earthfile index 989dbce65b..abbf261917 100644 --- a/catalyst_voices/Earthfile +++ b/catalyst_voices/Earthfile @@ -1,7 +1,7 @@ VERSION 0.8 IMPORT ../catalyst-gateway AS catalyst-gateway -IMPORT github.com/input-output-hk/catalyst-ci/earthly/flutter:v3.2.04 AS flutter-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/flutter:v3.2.07 AS flutter-ci # Copy all the necessary files and running bootstrap builder: @@ -52,6 +52,11 @@ check-package-publishing: FROM +builder DO flutter-ci+PUBLISH_DRY_RUN +# Runs the license checks for Catalyst Voices. +check-license: + FROM +builder + RUN melos run license-check + # Run unit tests test-unit: FROM +builder @@ -72,7 +77,7 @@ build-web: END package: - FROM nginx:alpine3.18 + FROM nginx:alpine3.20-slim ARG tag='latest' COPY +build-web/web /app @@ -83,6 +88,8 @@ package: publish: FROM +package - ARG tag='latest' - SAVE IMAGE voices-frontend:$tag \ No newline at end of file + ARG container="voices" + ARG tag="latest" + + SAVE IMAGE ${container}:${tag} \ No newline at end of file diff --git a/catalyst_voices/blueprint.cue b/catalyst_voices/blueprint.cue new file mode 100644 index 0000000000..c76d4145ee --- /dev/null +++ b/catalyst_voices/blueprint.cue @@ -0,0 +1,2 @@ +version: "1.0.0" +project: name: "voices" diff --git a/catalyst_voices/lib/common/ext/account_role_ext.dart b/catalyst_voices/lib/common/ext/account_role_ext.dart new file mode 100644 index 0000000000..070bc36808 --- /dev/null +++ b/catalyst_voices/lib/common/ext/account_role_ext.dart @@ -0,0 +1,16 @@ +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:flutter/material.dart'; + +extension AccountRoleExt on AccountRole { + String getName(BuildContext context) { + switch (this) { + case AccountRole.voter: + return context.l10n.voter; + case AccountRole.proposer: + return context.l10n.proposer; + case AccountRole.drep: + return context.l10n.drep; + } + } +} diff --git a/catalyst_voices/lib/dependency/dependencies.dart b/catalyst_voices/lib/dependency/dependencies.dart index 589ca073dd..713343dc16 100644 --- a/catalyst_voices/lib/dependency/dependencies.dart +++ b/catalyst_voices/lib/dependency/dependencies.dart @@ -27,13 +27,15 @@ final class Dependencies extends DependencyProvider { authenticationRepository: get(), ), ) - ..registerLazySingleton(SessionBloc.new); + ..registerLazySingleton(SessionBloc.new) + // Factory will rebuild it each time needed + ..registerFactory(RegistrationBloc.new); } void _registerRepositories() { this ..registerSingleton( - CredentialsStorageRepository(secureStorageService: get()), + CredentialsStorageRepository(storage: get()), ) ..registerSingleton( AuthenticationRepository(credentialsStorageRepository: get()), @@ -41,8 +43,8 @@ final class Dependencies extends DependencyProvider { } void _registerServices() { - registerSingleton( - SecureStorageService(), - ); + registerSingleton(const SecureStorage()); + registerSingleton(const SecureStorageVault()); + registerSingleton(const SecureDummyAuthStorage()); } } diff --git a/catalyst_voices/lib/pages/account/account.dart b/catalyst_voices/lib/pages/account/account.dart new file mode 100644 index 0000000000..343bf63edb --- /dev/null +++ b/catalyst_voices/lib/pages/account/account.dart @@ -0,0 +1 @@ +export 'account_page.dart'; diff --git a/catalyst_voices/lib/pages/account/account_page.dart b/catalyst_voices/lib/pages/account/account_page.dart new file mode 100644 index 0000000000..f591404d9f --- /dev/null +++ b/catalyst_voices/lib/pages/account/account_page.dart @@ -0,0 +1,251 @@ +import 'package:catalyst_voices/common/ext/account_role_ext.dart'; +import 'package:catalyst_voices/widgets/buttons/voices_icon_button.dart'; +import 'package:catalyst_voices/widgets/buttons/voices_text_button.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +final class AccountPage extends StatelessWidget { + const AccountPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + const _Header(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 32), + const _Tab(), + const SizedBox(height: 48), + _KeychainCard( + connectedWallet: 'Lace', + roles: const [ + AccountRole.voter, + AccountRole.proposer, + AccountRole.drep, + ], + defaultRole: AccountRole.voter, + onRemoveKeychain: () => debugPrint('Keychain removed'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header(); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: CatalystImage.asset( + VoicesAssets.images.accountBg.path, + ).image, + fit: BoxFit.cover, + ), + ), + child: SizedBox( + width: double.infinity, + height: 350, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only( + top: 24, + left: 8, + ), + child: VoicesIconButton.filled( + onTap: () { + GoRouter.of(context).pop(); + }, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + Theme.of(context).colors.elevationsOnSurfaceNeutralLv1White, + ), + foregroundColor: WidgetStateProperty.all( + Theme.of(context).colors.iconsForeground, + ), + ), + child: VoicesAssets.icons.arrowNarrowLeft.buildIcon(), + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + context.l10n.myAccountProfileKeychain, + style: Theme.of(context).textTheme.displayMedium?.copyWith( + color: Colors.white, + ), + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + context.l10n.yourCatalystKeychainAndRoleRegistration, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white, + ), + ), + ), + const SizedBox(height: 48), + ], + ), + ), + ); + } +} + +class _Tab extends StatelessWidget { + const _Tab(); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 1, + child: TabBar( + padding: const EdgeInsets.symmetric(horizontal: 20), + isScrollable: true, + tabs: [ + Tab(text: context.l10n.profileAndKeychain), + ], + ), + ); + } +} + +class _KeychainCard extends StatelessWidget { + final String? connectedWallet; + final List roles; + final AccountRole? defaultRole; + final VoidCallback? onRemoveKeychain; + + const _KeychainCard({ + this.connectedWallet, + this.roles = const [], + this.defaultRole, + this.onRemoveKeychain, + }); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(maxWidth: 600), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(16), + ), + border: Border.all( + color: Theme.of(context).colors.outlineBorderVariant!, + width: 1, + ), + color: Theme.of(context).colors.elevationsOnSurfaceNeutralLv1White, + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + context.l10n.catalystKeychain, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + VoicesTextButton.custom( + leading: VoicesAssets.icons.x.buildIcon(), + color: Theme.of(context).colors.iconsError, + onTap: onRemoveKeychain, + child: Text( + context.l10n.removeKeychain, + ), + ), + ], + ), + if (connectedWallet != null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + context.l10n.walletConnected, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (connectedWallet != null) + Row( + children: [ + VoicesIconButton.filled( + style: ButtonStyle( + padding: WidgetStateProperty.all(EdgeInsets.zero), + foregroundColor: WidgetStateProperty.all( + Theme.of(context).colors.successContainer, + ), + backgroundColor: WidgetStateProperty.all( + Theme.of(context).colors.success, + ), + ), + child: VoicesAssets.icons.check.buildIcon(), + ), + const SizedBox(width: 12), + Text( + connectedWallet!, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + if (roles.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + top: 40, + bottom: 24, + ), + child: Text( + context.l10n.currentRoleRegistrations, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (roles.isNotEmpty) + Text( + roles + .map((e) => _formatRoleBullet(e, defaultRole, context)) + .join('\n'), + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ); + } + + String _formatRoleBullet( + AccountRole role, + AccountRole? defaultRole, + BuildContext context, + ) { + String label; + if (role == defaultRole) { + label = '${role.getName(context)} (${context.l10n.defaultRole})'; + } else { + label = role.getName(context); + } + return ' • $label'; + } +} diff --git a/catalyst_voices/lib/pages/account/account_popup.dart b/catalyst_voices/lib/pages/account/account_popup.dart new file mode 100644 index 0000000000..44f91a83e2 --- /dev/null +++ b/catalyst_voices/lib/pages/account/account_popup.dart @@ -0,0 +1,253 @@ +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class AccountPopup extends StatelessWidget { + final String avatarLetter; + final VoidCallback? onProfileKeychainTap; + final VoidCallback? onLockAccountTap; + + const AccountPopup({ + super.key, + required this.avatarLetter, + this.onProfileKeychainTap, + this.onLockAccountTap, + }); + + @override + Widget build(BuildContext context) { + return PopupMenuButton<_MenuItemValue>( + color: Theme.of(context).colors.elevationsOnSurfaceNeutralLv1White, + onSelected: (_MenuItemValue value) { + switch (value) { + case _MenuItemValue.profileAndKeychain: + onProfileKeychainTap?.call(); + break; + case _MenuItemValue.lock: + onLockAccountTap?.call(); + break; + } + }, + itemBuilder: (BuildContext bc) { + return [ + PopupMenuItem( + padding: EdgeInsets.zero, + enabled: false, + value: null, + child: _Header( + accountLetter: avatarLetter, + walletName: 'Wallet name', + walletBalance: '₳ 1,750,000', + accountType: 'Basis', + walletAddress: 'addr1_H4543...45GH', + ), + ), + const PopupMenuItem( + height: 48, + padding: EdgeInsets.zero, + enabled: false, + value: null, + child: _Section('My account'), + ), + PopupMenuItem( + padding: EdgeInsets.zero, + value: _MenuItemValue.profileAndKeychain, + child: _MenuItem( + 'Profile & Keychain', + VoicesAssets.icons.userCircle, + ), + ), + PopupMenuItem( + padding: EdgeInsets.zero, + value: _MenuItemValue.lock, + child: _MenuItem( + 'Lock account', + VoicesAssets.icons.lockClosed, + showDivider: false, + ), + ), + ]; + }, + offset: const Offset(0, kToolbarHeight), + child: IgnorePointer( + child: VoicesAvatar( + icon: Text(avatarLetter), + ), + ), + ); + } +} + +class _Header extends StatelessWidget { + final String accountLetter; + final String walletName; + final String walletBalance; + final String accountType; + final String walletAddress; + + const _Header({ + required this.accountLetter, + required this.walletName, + required this.walletBalance, + required this.accountType, + required this.walletAddress, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: _padding), + child: Row( + children: [ + VoicesAvatar( + icon: Text(accountLetter), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(_padding), + child: Wrap( + children: [ + Text( + walletName, + style: Theme.of(context).textTheme.bodyLarge, + ), + Text( + walletBalance, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ), + VoicesChip.rectangular( + content: Text( + accountType, + style: TextStyle( + color: Theme.of(context).colors.successContainer, + ), + ), + backgroundColor: Theme.of(context).colors.success, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only( + left: _padding, + right: _padding, + bottom: _padding, + top: 8, + ), + child: Row( + children: [ + Expanded( + child: Text( + walletAddress, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + InkWell( + onTap: () async { + await Clipboard.setData( + ClipboardData(text: walletAddress), + ); + }, + child: VoicesAssets.icons.clipboardCopy.buildIcon(), + ), + ], + ), + ), + VoicesDivider( + height: 1, + color: Theme.of(context).colors.outlineBorder, + indent: 0, + endIndent: 0, + ), + ], + ); + } +} + +class _MenuItem extends StatelessWidget { + final String text; + final SvgGenImage icon; + final bool showDivider; + + const _MenuItem( + this.text, + this.icon, { + this.showDivider = true, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + height: 47, + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: _padding), + child: Row( + children: [ + icon.buildIcon(), + const SizedBox(width: _padding), + Text( + text, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + if (showDivider) + VoicesDivider( + height: 1, + color: Theme.of(context).colors.outlineBorderVariant, + indent: 0, + endIndent: 0, + ), + ], + ); + } +} + +class _Section extends StatelessWidget { + final String text; + + const _Section( + this.text, + ); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + height: 47, + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: _padding), + child: Text( + text, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + VoicesDivider( + height: 1, + color: Theme.of(context).colors.outlineBorderVariant, + indent: 0, + endIndent: 0, + ), + ], + ); + } +} + +const _padding = 12.0; + +enum _MenuItemValue { + profileAndKeychain, + lock, +} diff --git a/catalyst_voices/lib/pages/discovery/discovery_page.dart b/catalyst_voices/lib/pages/discovery/discovery_page.dart index 44dc96f11b..7049932a0d 100644 --- a/catalyst_voices/lib/pages/discovery/discovery_page.dart +++ b/catalyst_voices/lib/pages/discovery/discovery_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:catalyst_voices/pages/discovery/current_status_text.dart'; import 'package:catalyst_voices/pages/discovery/toggle_state_text.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; @@ -84,7 +86,16 @@ class _Segment extends StatelessWidget { const Spacer(), VoicesFilledButton( child: const Text('CTA to Model'), - onTap: () {}, + onTap: () { + unawaited( + VoicesDialog.show( + context: context, + builder: (context) { + return const VoicesDesktopInfoDialog(title: Text('')); + }, + ), + ); + }, ), ], ), diff --git a/catalyst_voices/lib/pages/registration/create_keychain/create_keychain_panel.dart b/catalyst_voices/lib/pages/registration/create_keychain/create_keychain_panel.dart new file mode 100644 index 0000000000..da7629c063 --- /dev/null +++ b/catalyst_voices/lib/pages/registration/create_keychain/create_keychain_panel.dart @@ -0,0 +1,28 @@ +import 'package:catalyst_voices/pages/registration/create_keychain/stage/stages.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:flutter/material.dart'; + +class CreateKeychainPanel extends StatelessWidget { + final CreateKeychainStage stage; + + const CreateKeychainPanel({ + super.key, + required this.stage, + }); + + @override + Widget build(BuildContext context) { + return switch (stage) { + CreateKeychainStage.splash => const SplashPanel(), + CreateKeychainStage.instructions => const InstructionsPanel(), + CreateKeychainStage.seedPhrase || + CreateKeychainStage.checkSeedPhraseInstructions || + CreateKeychainStage.checkSeedPhrase || + CreateKeychainStage.checkSeedPhraseResult || + CreateKeychainStage.unlockPasswordInstructions || + CreateKeychainStage.unlockPasswordCreate || + CreateKeychainStage.created => + const Placeholder(), + }; + } +} diff --git a/catalyst_voices/lib/pages/registration/create_keychain/stage/instructions_panel.dart b/catalyst_voices/lib/pages/registration/create_keychain/stage/instructions_panel.dart new file mode 100644 index 0000000000..c09e7b281a --- /dev/null +++ b/catalyst_voices/lib/pages/registration/create_keychain/stage/instructions_panel.dart @@ -0,0 +1,59 @@ +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; + +class InstructionsPanel extends StatelessWidget { + const InstructionsPanel({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textColor = theme.colors.textOnPrimaryLevel0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + context.l10n.accountInstructionsTitle, + style: theme.textTheme.titleMedium?.copyWith(color: textColor), + ), + const SizedBox(height: 24), + Text( + context.l10n.accountInstructionsMessage, + style: theme.textTheme.bodyMedium?.copyWith(color: textColor), + ), + const Spacer(), + const _Navigation(), + ], + ); + } +} + +class _Navigation extends StatelessWidget { + const _Navigation(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: VoicesBackButton( + onTap: () { + RegistrationBloc.of(context).add(const PreviousStepEvent()); + }, + ), + ), + const SizedBox(width: 10), + Expanded( + child: VoicesNextButton( + onTap: () { + RegistrationBloc.of(context).add(const NextStepEvent()); + }, + ), + ), + ], + ); + } +} diff --git a/catalyst_voices/lib/pages/registration/create_keychain/stage/splash_panel.dart b/catalyst_voices/lib/pages/registration/create_keychain/stage/splash_panel.dart new file mode 100644 index 0000000000..484cb67d31 --- /dev/null +++ b/catalyst_voices/lib/pages/registration/create_keychain/stage/splash_panel.dart @@ -0,0 +1,37 @@ +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; + +class SplashPanel extends StatelessWidget { + const SplashPanel({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textColor = theme.colors.textOnPrimaryLevel0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + context.l10n.accountCreationSplashTitle, + style: theme.textTheme.titleMedium?.copyWith(color: textColor), + ), + const SizedBox(height: 24), + Text( + context.l10n.accountCreationSplashMessage, + style: theme.textTheme.bodyMedium?.copyWith(color: textColor), + ), + const Spacer(), + VoicesFilledButton( + child: Text(context.l10n.accountCreationSplashNextButton), + onTap: () { + RegistrationBloc.of(context).add(const NextStepEvent()); + }, + ), + ], + ); + } +} diff --git a/catalyst_voices/lib/pages/registration/create_keychain/stage/stages.dart b/catalyst_voices/lib/pages/registration/create_keychain/stage/stages.dart new file mode 100644 index 0000000000..69b95fc8bc --- /dev/null +++ b/catalyst_voices/lib/pages/registration/create_keychain/stage/stages.dart @@ -0,0 +1,2 @@ +export 'instructions_panel.dart'; +export 'splash_panel.dart'; diff --git a/catalyst_voices/lib/pages/registration/get_started/get_started_panel.dart b/catalyst_voices/lib/pages/registration/get_started/get_started_panel.dart new file mode 100644 index 0000000000..30c15365c7 --- /dev/null +++ b/catalyst_voices/lib/pages/registration/get_started/get_started_panel.dart @@ -0,0 +1,140 @@ +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; + +class GetStartedPanel extends StatelessWidget { + const GetStartedPanel({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + Text( + context.l10n.accountCreationGetStartedTitle, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colors.textOnPrimaryLevel1, + ), + ), + const SizedBox(height: 12), + Text( + context.l10n.accountCreationGetStatedDesc, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colors.textOnPrimaryLevel1, + ), + ), + const SizedBox(height: 32), + Text( + context.l10n.accountCreationGetStatedWhatNext, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colors.textOnPrimaryLevel0, + ), + ), + const SizedBox(height: 24), + Column( + mainAxisSize: MainAxisSize.min, + children: CreateAccountType.values + .map((type) { + return _CreateAccountTypeTile( + key: ValueKey(type), + type: type, + onTap: () { + final event = CreateAccountTypeEvent(type: type); + RegistrationBloc.of(context).add(event); + }, + ); + }) + .separatedBy(const SizedBox(height: 12)) + .toList(), + ), + ], + ); + } +} + +class _CreateAccountTypeTile extends StatelessWidget { + final CreateAccountType type; + final VoidCallback? onTap; + + const _CreateAccountTypeTile({ + super.key, + required this.type, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ConstrainedBox( + constraints: const BoxConstraints.tightFor(height: 80), + child: Material( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + type._icon.buildIcon( + size: 48, + color: theme.colorScheme.onPrimary, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + type._getTitle(context.l10n), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + Text( + type._getSubtitle(context.l10n), + maxLines: 1, + overflow: TextOverflow.clip, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onPrimary, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +extension _CreateAccountTypeExt on CreateAccountType { + SvgGenImage get _icon => switch (this) { + CreateAccountType.createNew => VoicesAssets.icons.colorSwatch, + CreateAccountType.recover => VoicesAssets.icons.download, + }; + + String _getTitle(VoicesLocalizations l10n) => switch (this) { + CreateAccountType.createNew => l10n.accountCreationCreate, + CreateAccountType.recover => l10n.accountCreationRecover, + }; + + String _getSubtitle(VoicesLocalizations l10n) { + return l10n.accountCreationOnThisDevice; + } +} diff --git a/catalyst_voices/lib/pages/registration/information_panel.dart b/catalyst_voices/lib/pages/registration/information_panel.dart new file mode 100644 index 0000000000..4d15bc3539 --- /dev/null +++ b/catalyst_voices/lib/pages/registration/information_panel.dart @@ -0,0 +1,109 @@ +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; +import 'package:flutter/material.dart'; + +class InformationPanel extends StatelessWidget { + final String title; + final String? subtitle; + final String? body; + final Widget picture; + final double? progress; + + const InformationPanel({ + super.key, + required this.title, + this.subtitle, + this.body, + required this.picture, + this.progress, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Header( + title: title, + subtitle: subtitle, + body: body, + ), + const SizedBox(height: 12), + Expanded(child: Center(child: picture)), + const SizedBox(height: 12), + _Footer( + progress: progress, + ), + ], + ); + } +} + +class _Header extends StatelessWidget { + final String title; + final String? subtitle; + final String? body; + + const _Header({ + required this.title, + this.subtitle, + this.body, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textColor = theme.colors.textOnPrimaryLevel0; + + final subtitle = this.subtitle; + final body = this.body; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith(color: textColor), + ), + if (subtitle != null) + Text( + subtitle, + style: theme.textTheme.titleMedium?.copyWith(color: textColor), + ), + if (body != null) + Text( + body, + style: theme.textTheme.bodyMedium?.copyWith(color: textColor), + ), + ].separatedBy(const SizedBox(height: 12)).toList(), + ); + } +} + +class _Footer extends StatelessWidget { + final double? progress; + + const _Footer({ + this.progress, + }); + + @override + Widget build(BuildContext context) { + final progress = this.progress; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Offstage( + offstage: progress == null, + child: VoicesLinearProgressIndicator(value: progress ?? 0), + ), + const SizedBox(height: 10), + VoicesLearnMoreButton(onTap: () {}), + ], + ); + } +} diff --git a/catalyst_voices/lib/pages/registration/link_wallet/intro/intro_panel.dart b/catalyst_voices/lib/pages/registration/link_wallet/intro/intro_panel.dart new file mode 100644 index 0000000000..c55a067c1d --- /dev/null +++ b/catalyst_voices/lib/pages/registration/link_wallet/intro/intro_panel.dart @@ -0,0 +1,36 @@ +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:flutter/material.dart'; + +class IntroPanel extends StatelessWidget { + const IntroPanel({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + Text( + context.l10n.walletLink_intro_title, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 24), + Text( + context.l10n.walletLink_intro_content, + style: Theme.of(context).textTheme.bodyMedium, + ), + const Spacer(), + VoicesFilledButton( + leading: VoicesAssets.icons.wallet.buildIcon(), + onTap: () { + RegistrationBloc.of(context).add(const NextStepEvent()); + }, + child: Text(context.l10n.chooseCardanoWallet), + ), + ], + ); + } +} diff --git a/catalyst_voices/lib/pages/registration/link_wallet/select_wallet/select_wallet_panel.dart b/catalyst_voices/lib/pages/registration/link_wallet/select_wallet/select_wallet_panel.dart new file mode 100644 index 0000000000..855808d8f0 --- /dev/null +++ b/catalyst_voices/lib/pages/registration/link_wallet/select_wallet/select_wallet_panel.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class SelectWalletPanel extends StatelessWidget { + const SelectWalletPanel({super.key}); + + @override + Widget build(BuildContext context) { + return const Placeholder(); + } +} diff --git a/catalyst_voices/lib/pages/registration/link_wallet/wallet_link_panel.dart b/catalyst_voices/lib/pages/registration/link_wallet/wallet_link_panel.dart new file mode 100644 index 0000000000..dcb668f4e9 --- /dev/null +++ b/catalyst_voices/lib/pages/registration/link_wallet/wallet_link_panel.dart @@ -0,0 +1,21 @@ +import 'package:catalyst_voices/pages/registration/link_wallet/intro/intro_panel.dart'; +import 'package:catalyst_voices/pages/registration/link_wallet/select_wallet/select_wallet_panel.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:flutter/material.dart'; + +class WalletLinkPanel extends StatelessWidget { + final WalletLinkStage stage; + + const WalletLinkPanel({ + super.key, + required this.stage, + }); + + @override + Widget build(BuildContext context) { + return switch (stage) { + WalletLinkStage.intro => const IntroPanel(), + WalletLinkStage.selectWallet => const SelectWalletPanel(), + }; + } +} diff --git a/catalyst_voices/lib/pages/registration/registration_dialog.dart b/catalyst_voices/lib/pages/registration/registration_dialog.dart new file mode 100644 index 0000000000..653764e746 --- /dev/null +++ b/catalyst_voices/lib/pages/registration/registration_dialog.dart @@ -0,0 +1,57 @@ +import 'package:catalyst_voices/dependency/dependencies.dart'; +import 'package:catalyst_voices/pages/registration/create_keychain/create_keychain_panel.dart'; +import 'package:catalyst_voices/pages/registration/get_started/get_started_panel.dart'; +import 'package:catalyst_voices/pages/registration/link_wallet/wallet_link_panel.dart'; +import 'package:catalyst_voices/pages/registration/registration_info_panel.dart'; +import 'package:catalyst_voices/widgets/widgets.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class RegistrationDialog extends StatelessWidget { + const RegistrationDialog._(); + + static Future show(BuildContext context) { + return VoicesDialog.show( + context: context, + routeSettings: const RouteSettings(name: '/registration'), + builder: (context) => const RegistrationDialog._(), + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => Dependencies.instance.get(), + child: BlocBuilder( + builder: (context, state) { + return _RegistrationDialog(state: state); + }, + ), + ); + } +} + +class _RegistrationDialog extends StatelessWidget { + final RegistrationState state; + + const _RegistrationDialog({ + required this.state, + }); + + @override + Widget build(BuildContext context) { + return VoicesDesktopPanelsDialog( + left: RegistrationInfoPanel( + state: state, + ), + right: switch (state) { + GetStarted() => const GetStartedPanel(), + FinishAccountCreation() => const Placeholder(), + Recover() => const Placeholder(), + CreateKeychain(:final stage) => CreateKeychainPanel(stage: stage), + WalletLink(:final stage) => WalletLinkPanel(stage: stage), + }, + ); + } +} diff --git a/catalyst_voices/lib/pages/registration/registration_info_panel.dart b/catalyst_voices/lib/pages/registration/registration_info_panel.dart new file mode 100644 index 0000000000..e33113d351 --- /dev/null +++ b/catalyst_voices/lib/pages/registration/registration_info_panel.dart @@ -0,0 +1,85 @@ +import 'package:catalyst_voices/pages/registration/information_panel.dart'; +import 'package:catalyst_voices/pages/registration/task_picture.dart'; +import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:flutter/material.dart'; + +class _HeaderStrings { + final String title; + final String? subtitle; + final String? body; + + _HeaderStrings({ + required this.title, + this.subtitle, + this.body, + }); +} + +class RegistrationInfoPanel extends StatelessWidget { + final RegistrationState state; + + const RegistrationInfoPanel({ + super.key, + required this.state, + }); + + @override + Widget build(BuildContext context) { + final headerStrings = _buildHeaderStrings(context); + + return InformationPanel( + title: headerStrings.title, + subtitle: headerStrings.subtitle, + body: headerStrings.body, + picture: const TaskKeychainPicture(), + ); + } + + _HeaderStrings _buildHeaderStrings(BuildContext context) { + _HeaderStrings buildKeychainStageHeader(CreateKeychainStage stage) { + return switch (stage) { + CreateKeychainStage.splash || + CreateKeychainStage.instructions => + _HeaderStrings(title: context.l10n.catalystKeychain), + + // TODO(damian-molinski): Extract to l10n in next step + CreateKeychainStage.seedPhrase => _HeaderStrings( + title: 'Catalyst Keychain', + subtitle: 'Write down your 12 Catalyst 
security words', + body: 'Make sure you create an offline backup ' + 'of your recovery phrase as well.', + ), + CreateKeychainStage.checkSeedPhraseInstructions || + CreateKeychainStage.checkSeedPhrase || + CreateKeychainStage.checkSeedPhraseResult || + CreateKeychainStage.unlockPasswordInstructions || + CreateKeychainStage.unlockPasswordCreate || + CreateKeychainStage.created => + _HeaderStrings(title: 'TODO'), + }; + } + + _HeaderStrings buildWalletStageHeader(WalletLinkStage stage) { + return switch (stage) { + WalletLinkStage.intro => _HeaderStrings( + title: 'Link keys to your 
Catalyst Keychain', + subtitle: 'Link your Cardano wallet', + ), + WalletLinkStage.selectWallet => _HeaderStrings( + title: 'Link keys to your 
Catalyst Keychain', + subtitle: 'Link your Cardano wallet', + ), + }; + } + + return switch (state) { + GetStarted() => _HeaderStrings(title: context.l10n.getStarted), + FinishAccountCreation() => _HeaderStrings(title: 'TODO'), + Recover() => _HeaderStrings(title: 'TODO'), + CreateKeychain(:final stage) => buildKeychainStageHeader(stage), + WalletLink(:final stage) => buildWalletStageHeader(stage), + }; + } +} diff --git a/catalyst_voices/lib/pages/registration/task_picture.dart b/catalyst_voices/lib/pages/registration/task_picture.dart new file mode 100644 index 0000000000..04d9cb038d --- /dev/null +++ b/catalyst_voices/lib/pages/registration/task_picture.dart @@ -0,0 +1,123 @@ +import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; +import 'package:flutter/material.dart'; + +enum TaskPictureType { + normal, + success, + error; + + Color _foregroundColor(ThemeData theme) { + return switch (this) { + // TODO(damian-molinski): Color should come from colors scheme + TaskPictureType.normal => const Color(0xFF0C288D), + TaskPictureType.success => theme.colors.successContainer!, + TaskPictureType.error => theme.colors.errorContainer!, + }; + } + + Color _backgroundColor(ThemeData theme) { + return switch (this) { + // TODO(damian-molinski): Color should come from colors scheme + TaskPictureType.normal => const Color(0xFFCCE2FF), + TaskPictureType.success => theme.colors.success!, + TaskPictureType.error => theme.colorScheme.error, + }; + } +} + +class TaskKeychainPicture extends StatelessWidget { + final TaskPictureType type; + + const TaskKeychainPicture({ + super.key, + this.type = TaskPictureType.normal, + }); + + @override + Widget build(BuildContext context) { + return TaskPicture( + child: TaskPictureIconBox( + type: type, + child: VoicesAssets.images.keychain.buildIcon(allowSize: false), + ), + ); + } +} + +class TaskPicture extends StatelessWidget { + final Size preferredSize; + final Widget child; + + // Original size is 125 but we want to have it scale with overall picture + static const _childSizeFactor = 125 / 354; + + const TaskPicture({ + super.key, + // Original asset sizes. "Magic number" from figma. + this.preferredSize = const Size(354, 340), + required this.child, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final size = constraints + .constrainSizeAndAttemptToPreserveAspectRatio(preferredSize); + final childSize = Size.square(size.width * _childSizeFactor); + + return SizedBox.fromSize( + size: size, + child: Stack( + alignment: Alignment.topRight, + children: [ + CatalystImage.asset( + VoicesAssets.images.taskIllustration.path, + width: size.width, + height: size.height, + ), + ConstrainedBox( + constraints: BoxConstraints.tight(childSize), + child: child, + ), + ], + ), + ); + }, + ); + } +} + +class TaskPictureIconBox extends StatelessWidget { + final TaskPictureType type; + final Widget child; + + const TaskPictureIconBox({ + super.key, + this.type = TaskPictureType.normal, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final foregroundColor = type._foregroundColor(theme); + final backgroundColor = type._backgroundColor(theme); + + final iconThemeData = IconThemeData(color: foregroundColor); + + return IconTheme( + data: iconThemeData, + child: Container( + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: child, + ), + ); + } +} diff --git a/catalyst_voices/lib/pages/spaces/spaces_shell_page.dart b/catalyst_voices/lib/pages/spaces/spaces_shell_page.dart index 26b10d01de..ebea205e41 100644 --- a/catalyst_voices/lib/pages/spaces/spaces_shell_page.dart +++ b/catalyst_voices/lib/pages/spaces/spaces_shell_page.dart @@ -1,4 +1,5 @@ import 'package:catalyst_voices/common/ext/ext.dart'; +import 'package:catalyst_voices/pages/registration/registration_dialog.dart'; import 'package:catalyst_voices/pages/spaces/drawer/spaces_drawer.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; @@ -7,7 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class SpacesShellPage extends StatelessWidget { +class SpacesShellPage extends StatefulWidget { final Space space; final Widget child; @@ -40,6 +41,11 @@ class SpacesShellPage extends StatelessWidget { required this.child, }); + @override + State createState() => _SpacesShellPageState(); +} + +class _SpacesShellPageState extends State { @override Widget build(BuildContext context) { final sessionBloc = context.watch(); @@ -48,27 +54,34 @@ class SpacesShellPage extends StatelessWidget { return CallbackShortcuts( bindings: { - for (final entry in _spacesShortcutsActivators.entries) + for (final entry in SpacesShellPage._spacesShortcutsActivators.entries) entry.value: () => entry.key.go(context), }, child: Scaffold( appBar: VoicesAppBar( leading: isVisitor ? null : const DrawerToggleButton(), automaticallyImplyLeading: false, - actions: const [ - SessionActionHeader(), - SessionStateHeader(), + actions: [ + SessionActionHeader( + onGetStartedTap: _showAccountSetup, + ), + const SessionStateHeader(), ], ), drawer: isVisitor ? null : SpacesDrawer( - space: space, - spacesShortcutsActivators: _spacesShortcutsActivators, + space: widget.space, + spacesShortcutsActivators: + SpacesShellPage._spacesShortcutsActivators, isUnlocked: isUnlocked, ), - body: child, + body: widget.child, ), ); } + + Future _showAccountSetup() async { + await RegistrationDialog.show(context); + } } diff --git a/catalyst_voices/lib/pages/workspace/workspace_page.dart b/catalyst_voices/lib/pages/workspace/workspace_page.dart index ae19d06783..96de89afa6 100644 --- a/catalyst_voices/lib/pages/workspace/workspace_page.dart +++ b/catalyst_voices/lib/pages/workspace/workspace_page.dart @@ -3,8 +3,6 @@ import 'package:catalyst_voices/pages/workspace/proposal_navigation_panel.dart'; import 'package:catalyst_voices/pages/workspace/proposal_segment_controller.dart'; import 'package:catalyst_voices/pages/workspace/proposal_setup_panel.dart'; import 'package:catalyst_voices/pages/workspace/sample_rich_text.dart'; -import 'package:catalyst_voices/pages/workspace/sample_rich_text2.dart'; -import 'package:catalyst_voices/pages/workspace/sample_rich_text3.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:flutter/material.dart'; @@ -17,31 +15,12 @@ final _proposalNavigation = WorkspaceProposalNavigation( WorkspaceProposalSetup( id: _setupSegmentId, steps: [ - const WorkspaceProposalSegmentStep( - id: 0, - title: 'Title', - description: 'F14 / Promote Social Entrepreneurs and a ' - 'longer title up-to 60 characters', - isEditable: true, - ), WorkspaceProposalSegmentStep( - id: 1, + id: 0, title: 'Rich text', document: Document.fromJson(sampleRichText), isEditable: true, ), - WorkspaceProposalSegmentStep( - id: 2, - title: 'Other topic', - document: Document.fromJson(sampleRichText2), - isEditable: false, - ), - WorkspaceProposalSegmentStep( - id: 3, - title: 'Other topic', - document: Document.fromJson(sampleRichText3), - isEditable: false, - ), ], ), ], diff --git a/catalyst_voices/lib/routes/guards/milestone_guard.dart b/catalyst_voices/lib/routes/guards/milestone_guard.dart index a27f5e7083..e661e57aa6 100644 --- a/catalyst_voices/lib/routes/guards/milestone_guard.dart +++ b/catalyst_voices/lib/routes/guards/milestone_guard.dart @@ -12,13 +12,20 @@ final class MilestoneGuard implements RouteGuard { @override FutureOr redirect(BuildContext context, GoRouterState state) { + final location = state.uri.toString(); + + // redirects /m4 page to /m4/discovery + if (location == '/${Routes.currentMilestone}') { + return const DiscoveryRoute().location; + } + // allow milestone sub pages - if (state.uri.toString().startsWith('/${Routes.currentMilestone}')) { + if (location.startsWith('/${Routes.currentMilestone}')) { return null; } // if already at destination skip redirect - if (state.uri.toString() == const ComingSoonRoute().location) { + if (location == const ComingSoonRoute().location) { return null; } diff --git a/catalyst_voices/lib/routes/routing/account_route.dart b/catalyst_voices/lib/routes/routing/account_route.dart new file mode 100644 index 0000000000..75cfb4ed9a --- /dev/null +++ b/catalyst_voices/lib/routes/routing/account_route.dart @@ -0,0 +1,19 @@ +import 'package:catalyst_voices/pages/account/account_page.dart'; +import 'package:catalyst_voices/routes/routing/routes.dart'; +import 'package:catalyst_voices/routes/routing/transitions/fade_page_transition_mixin.dart'; +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; + +part 'account_route.g.dart'; + +@TypedGoRoute( + path: '/${Routes.currentMilestone}/account', +) +final class AccountRoute extends GoRouteData with FadePageTransitionMixin { + const AccountRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) { + return const AccountPage(); + } +} diff --git a/catalyst_voices/lib/routes/routing/account_route.g.dart b/catalyst_voices/lib/routes/routing/account_route.g.dart new file mode 100644 index 0000000000..c6fd453950 --- /dev/null +++ b/catalyst_voices/lib/routes/routing/account_route.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'account_route.dart'; + +// ************************************************************************** +// GoRouterGenerator +// ************************************************************************** + +List get $appRoutes => [ + $accountRoute, + ]; + +RouteBase get $accountRoute => GoRouteData.$route( + path: '/m4/account', + factory: $AccountRouteExtension._fromState, + ); + +extension $AccountRouteExtension on AccountRoute { + static AccountRoute _fromState(GoRouterState state) => const AccountRoute(); + + String get location => GoRouteData.$location( + '/m4/account', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} diff --git a/catalyst_voices/lib/routes/routing/routes.dart b/catalyst_voices/lib/routes/routing/routes.dart index e44d80c1a1..e533df0b65 100644 --- a/catalyst_voices/lib/routes/routing/routes.dart +++ b/catalyst_voices/lib/routes/routing/routes.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_voices/routes/routing/account_route.dart' as account; import 'package:catalyst_voices/routes/routing/coming_soon_route.dart' as coming_soon; import 'package:catalyst_voices/routes/routing/login_route.dart' as login; @@ -11,14 +12,15 @@ import 'package:go_router/go_router.dart'; abstract final class Routes { static const currentMilestone = 'm4'; + static final List routes = [ + ...account.$appRoutes, + ...coming_soon.$appRoutes, + ...login.$appRoutes, + ...spaces.$appRoutes, + ...overall_spaces.$appRoutes, + ]; + static String get initialLocation { return const coming_soon.ComingSoonRoute().location; } - - static List get routes => [ - ...coming_soon.$appRoutes, - ...login.$appRoutes, - ...spaces.$appRoutes, - ...overall_spaces.$appRoutes, - ]; } diff --git a/catalyst_voices/lib/routes/routing/routing.dart b/catalyst_voices/lib/routes/routing/routing.dart index fbec95ce4c..e531151988 100644 --- a/catalyst_voices/lib/routes/routing/routing.dart +++ b/catalyst_voices/lib/routes/routing/routing.dart @@ -1,3 +1,4 @@ +export 'account_route.dart' hide $appRoutes; export 'coming_soon_route.dart' hide $appRoutes; export 'login_route.dart' hide $appRoutes; export 'overall_spaces_route.dart' hide $appRoutes; diff --git a/catalyst_voices/lib/widgets/app_bar/session/session_action_header.dart b/catalyst_voices/lib/widgets/app_bar/session/session_action_header.dart index 7d52d730a2..d354771106 100644 --- a/catalyst_voices/lib/widgets/app_bar/session/session_action_header.dart +++ b/catalyst_voices/lib/widgets/app_bar/session/session_action_header.dart @@ -8,14 +8,19 @@ import 'package:flutter_bloc/flutter_bloc.dart'; /// Displays current session action and toggling to next when clicked. class SessionActionHeader extends StatelessWidget { - const SessionActionHeader({super.key}); + final VoidCallback? onGetStartedTap; + + const SessionActionHeader({ + super.key, + this.onGetStartedTap, + }); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return switch (state) { - VisitorSessionState() => const _GetStartedButton(), + VisitorSessionState() => _GetStartedButton(onTap: onGetStartedTap), GuestSessionState() => const _UnlockButton(), ActiveUserSessionState() => const _LockButton(), }; @@ -25,14 +30,16 @@ class SessionActionHeader extends StatelessWidget { } class _GetStartedButton extends StatelessWidget { - const _GetStartedButton(); + final VoidCallback? onTap; + + const _GetStartedButton({ + this.onTap, + }); @override Widget build(BuildContext context) { return VoicesFilledButton( - onTap: () { - context.read().add(const ActiveUserSessionEvent()); - }, + onTap: onTap, child: Text(context.l10n.getStarted), ); } diff --git a/catalyst_voices/lib/widgets/app_bar/session/session_state_header.dart b/catalyst_voices/lib/widgets/app_bar/session/session_state_header.dart index 8f110ec863..79eb2e0fb0 100644 --- a/catalyst_voices/lib/widgets/app_bar/session/session_state_header.dart +++ b/catalyst_voices/lib/widgets/app_bar/session/session_state_header.dart @@ -1,3 +1,7 @@ +import 'dart:async'; + +import 'package:catalyst_voices/pages/account/account_popup.dart'; +import 'package:catalyst_voices/routes/routing/account_route.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; @@ -15,8 +19,13 @@ class SessionStateHeader extends StatelessWidget { return switch (state) { VisitorSessionState() => const _VisitorButton(), GuestSessionState() => const _GuestButton(), - ActiveUserSessionState(:final user) => - _ActiveUserAvatar(letter: user.acronym ?? 'A'), + ActiveUserSessionState(:final user) => AccountPopup( + avatarLetter: user.acronym ?? 'A', + onLockAccountTap: () => debugPrint('Lock account'), + onProfileKeychainTap: () => unawaited( + const AccountRoute().push(context), + ), + ), }; }, ); @@ -46,18 +55,3 @@ class _VisitorButton extends StatelessWidget { ); } } - -class _ActiveUserAvatar extends StatelessWidget { - final String letter; - - const _ActiveUserAvatar({ - required this.letter, - }); - - @override - Widget build(BuildContext context) { - return VoicesAvatar( - icon: Text(letter), - ); - } -} diff --git a/catalyst_voices/lib/widgets/buttons/voices_buttons.dart b/catalyst_voices/lib/widgets/buttons/voices_buttons.dart index 1d9cba19a1..3b5c495f1d 100644 --- a/catalyst_voices/lib/widgets/buttons/voices_buttons.dart +++ b/catalyst_voices/lib/widgets/buttons/voices_buttons.dart @@ -1,7 +1,11 @@ import 'dart:async'; +import 'package:catalyst_voices/widgets/buttons/voices_filled_button.dart'; import 'package:catalyst_voices/widgets/buttons/voices_icon_button.dart'; +import 'package:catalyst_voices/widgets/buttons/voices_outlined_button.dart'; +import 'package:catalyst_voices/widgets/buttons/voices_text_button.dart'; import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; +import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:flutter/material.dart'; class DrawerToggleButton extends StatelessWidget { @@ -146,3 +150,56 @@ class MoreOptionsButton extends StatelessWidget { ); } } + +/// A "Learn More" button that redirects usually to an external content. +class VoicesLearnMoreButton extends StatelessWidget { + final VoidCallback onTap; + + const VoicesLearnMoreButton({ + super.key, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return VoicesTextButton( + trailing: VoicesAssets.icons.externalLink.buildIcon(), + onTap: onTap, + child: Text(context.l10n.learnMore), + ); + } +} + +class VoicesNextButton extends StatelessWidget { + final VoidCallback? onTap; + + const VoicesNextButton({ + super.key, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return VoicesFilledButton( + onTap: onTap, + child: Text(context.l10n.next), + ); + } +} + +class VoicesBackButton extends StatelessWidget { + final VoidCallback? onTap; + + const VoicesBackButton({ + super.key, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return VoicesOutlinedButton( + onTap: onTap, + child: Text(context.l10n.back), + ); + } +} diff --git a/catalyst_voices/lib/widgets/buttons/voices_text_button.dart b/catalyst_voices/lib/widgets/buttons/voices_text_button.dart index d9ce555aaa..0e1c8b540a 100644 --- a/catalyst_voices/lib/widgets/buttons/voices_text_button.dart +++ b/catalyst_voices/lib/widgets/buttons/voices_text_button.dart @@ -2,7 +2,7 @@ import 'package:catalyst_voices/widgets/buttons/voices_button_affix_decoration.d import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:flutter/material.dart'; -enum _Variant { primary, neutral, secondary } +enum _Variant { primary, neutral, secondary, custom } /// A button that combines a `TextButton` with optional leading and trailing /// elements. @@ -22,6 +22,9 @@ class VoicesTextButton extends StatelessWidget { /// The main content of the button. final Widget child; + /// The foreground color of the button. + final Color? color; + final _Variant _variant; const VoicesTextButton({ @@ -29,6 +32,7 @@ class VoicesTextButton extends StatelessWidget { this.onTap, this.leading, this.trailing, + this.color, required this.child, }) : _variant = _Variant.primary; @@ -37,6 +41,7 @@ class VoicesTextButton extends StatelessWidget { this.onTap, this.leading, this.trailing, + this.color, required this.child, }) : _variant = _Variant.neutral; @@ -45,9 +50,19 @@ class VoicesTextButton extends StatelessWidget { this.onTap, this.leading, this.trailing, + this.color, required this.child, }) : _variant = _Variant.secondary; + const VoicesTextButton.custom({ + super.key, + this.onTap, + this.leading, + this.trailing, + required this.color, + required this.child, + }) : _variant = _Variant.custom; + @override Widget build(BuildContext context) { return TextButton( @@ -75,6 +90,9 @@ class VoicesTextButton extends StatelessWidget { _Variant.secondary => TextButton.styleFrom( foregroundColor: Theme.of(context).colorScheme.secondary, ), + _Variant.custom => TextButton.styleFrom( + foregroundColor: color, + ), }; } } diff --git a/catalyst_voices/lib/widgets/rich_text/voices_rich_text.dart b/catalyst_voices/lib/widgets/rich_text/voices_rich_text.dart index 697060c9c5..af4ee01f1f 100644 --- a/catalyst_voices/lib/widgets/rich_text/voices_rich_text.dart +++ b/catalyst_voices/lib/widgets/rich_text/voices_rich_text.dart @@ -150,7 +150,7 @@ class _Editor extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: ResizableBoxParent( - minHeight: 320, + minHeight: 470, resizableVertically: true, resizableHorizontally: false, child: DecoratedBox( diff --git a/catalyst_voices/packages/catalyst_voices_assets/assets/images/2.0x/task_illustration.webp b/catalyst_voices/packages/catalyst_voices_assets/assets/images/2.0x/task_illustration.webp new file mode 100644 index 0000000000..c55e59b9ff Binary files /dev/null and b/catalyst_voices/packages/catalyst_voices_assets/assets/images/2.0x/task_illustration.webp differ diff --git a/catalyst_voices/packages/catalyst_voices_assets/assets/images/3.0x/task_illustration.webp b/catalyst_voices/packages/catalyst_voices_assets/assets/images/3.0x/task_illustration.webp new file mode 100644 index 0000000000..afaaa67d4f Binary files /dev/null and b/catalyst_voices/packages/catalyst_voices_assets/assets/images/3.0x/task_illustration.webp differ diff --git a/catalyst_voices/packages/catalyst_voices_assets/assets/images/account_bg.png b/catalyst_voices/packages/catalyst_voices_assets/assets/images/account_bg.png new file mode 100644 index 0000000000..8a3c87e0c3 Binary files /dev/null and b/catalyst_voices/packages/catalyst_voices_assets/assets/images/account_bg.png differ diff --git a/catalyst_voices/packages/catalyst_voices_assets/assets/images/keychain.svg b/catalyst_voices/packages/catalyst_voices_assets/assets/images/keychain.svg new file mode 100644 index 0000000000..5c04db0777 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_assets/assets/images/keychain.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/catalyst_voices/packages/catalyst_voices_assets/assets/images/task_illustration.webp b/catalyst_voices/packages/catalyst_voices_assets/assets/images/task_illustration.webp new file mode 100644 index 0000000000..86db01c353 Binary files /dev/null and b/catalyst_voices/packages/catalyst_voices_assets/assets/images/task_illustration.webp differ diff --git a/catalyst_voices/packages/catalyst_voices_assets/lib/generated/assets.gen.dart b/catalyst_voices/packages/catalyst_voices_assets/lib/generated/assets.gen.dart index 6eb993c8b4..c58ee27460 100644 --- a/catalyst_voices/packages/catalyst_voices_assets/lib/generated/assets.gen.dart +++ b/catalyst_voices/packages/catalyst_voices_assets/lib/generated/assets.gen.dart @@ -1157,6 +1157,10 @@ class $AssetsIconsGen { class $AssetsImagesGen { const $AssetsImagesGen(); + /// File path: assets/images/account_bg.png + AssetGenImage get accountBg => + const AssetGenImage('assets/images/account_bg.png'); + /// File path: assets/images/catalyst_logo.svg SvgGenImage get catalystLogo => const SvgGenImage('assets/images/catalyst_logo.svg'); @@ -1199,6 +1203,9 @@ class $AssetsImagesGen { SvgGenImage get fallbackLogoIcon => const SvgGenImage('assets/images/fallback_logo_icon.svg'); + /// File path: assets/images/keychain.svg + SvgGenImage get keychain => const SvgGenImage('assets/images/keychain.svg'); + /// File path: assets/images/linkedin.svg SvgGenImage get linkedin => const SvgGenImage('assets/images/linkedin.svg'); @@ -1214,6 +1221,10 @@ class $AssetsImagesGen { AssetGenImage get proposalBackground2 => const AssetGenImage('assets/images/proposal_background_2.webp'); + /// File path: assets/images/task_illustration.webp + AssetGenImage get taskIllustration => + const AssetGenImage('assets/images/task_illustration.webp'); + /// File path: assets/images/x.svg SvgGenImage get x => const SvgGenImage('assets/images/x.svg'); @@ -1222,6 +1233,7 @@ class $AssetsImagesGen { /// List of all assets List get values => [ + accountBg, catalystLogo, catalystLogoIcon, catalystLogoIconWhite, @@ -1233,10 +1245,12 @@ class $AssetsImagesGen { facebookMono, fallbackLogo, fallbackLogoIcon, + keychain, linkedin, linkedinMono, proposalBackground1, proposalBackground2, + taskIllustration, x, xMono ]; diff --git a/catalyst_voices/packages/catalyst_voices_assets/lib/generated/colors.gen.dart b/catalyst_voices/packages/catalyst_voices_assets/lib/generated/colors.gen.dart index 39255c360d..8932c457c8 100644 --- a/catalyst_voices/packages/catalyst_voices_assets/lib/generated/colors.gen.dart +++ b/catalyst_voices/packages/catalyst_voices_assets/lib/generated/colors.gen.dart @@ -227,7 +227,7 @@ class VoicesColors { static const Color lightAvatarsWarning = Color(0xFFFDE1CE); /// Color: #FFFFFF - static const Color lightElevationsOnSurfaceNeutralLv0 = Color(0x16123CD3); + static const Color lightElevationsOnSurfaceNeutralLv0 = Color(0xFFFFFFFF); /// Color: #F2F4F8 static const Color lightElevationsOnSurfaceNeutralLv1Grey = Color(0xFFF2F4F8); diff --git a/catalyst_voices/packages/catalyst_voices_assets/lib/src/assets_ext.dart b/catalyst_voices/packages/catalyst_voices_assets/lib/src/assets_ext.dart index a52047b0e2..74eb0768b2 100644 --- a/catalyst_voices/packages/catalyst_voices_assets/lib/src/assets_ext.dart +++ b/catalyst_voices/packages/catalyst_voices_assets/lib/src/assets_ext.dart @@ -81,6 +81,7 @@ extension SvgGenImageExt on SvgGenImage { String? semanticsLabel, bool excludeFromSemantics = false, double? size, + bool allowSize = true, BoxFit fit = BoxFit.contain, Alignment alignment = Alignment.center, bool matchTextDirection = false, @@ -105,6 +106,7 @@ extension SvgGenImageExt on SvgGenImage { semanticsLabel: semanticsLabel, excludeFromSemantics: excludeFromSemantics, size: size, + allowSize: allowSize, fit: fit, alignment: alignment, matchTextDirection: matchTextDirection, diff --git a/catalyst_voices/packages/catalyst_voices_assets/lib/src/catalyst_svg_icon.dart b/catalyst_voices/packages/catalyst_voices_assets/lib/src/catalyst_svg_icon.dart index 88a827dfeb..0a9b094c8b 100644 --- a/catalyst_voices/packages/catalyst_voices_assets/lib/src/catalyst_svg_icon.dart +++ b/catalyst_voices/packages/catalyst_voices_assets/lib/src/catalyst_svg_icon.dart @@ -10,6 +10,9 @@ class CatalystSvgIcon extends StatelessWidget { /// See [SvgPicture.width] and [SvgPicture.height] final double? size; + /// Whether [size] can be applied to final widget. + final bool allowSize; + /// See [SvgPicture.fit] final BoxFit fit; @@ -54,6 +57,7 @@ class CatalystSvgIcon extends StatelessWidget { this.bytesLoader, { super.key, this.size, + this.allowSize = true, this.fit = BoxFit.contain, this.alignment = Alignment.center, this.matchTextDirection = false, @@ -77,6 +81,7 @@ class CatalystSvgIcon extends StatelessWidget { String? package = 'catalyst_voices_assets', SvgTheme? theme, this.size, + this.allowSize = true, this.fit = BoxFit.contain, this.alignment = Alignment.center, this.matchTextDirection = false, @@ -101,7 +106,7 @@ class CatalystSvgIcon extends StatelessWidget { @override Widget build(BuildContext context) { - final effectiveSize = size ?? IconTheme.of(context).size; + final effectiveSize = allowSize ? size ?? IconTheme.of(context).size : null; final effectiveColorFilter = allowColorFilter ? _colorFilter ?? IconTheme.of(context).asColorFilter() : null; diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/catalyst_voices_blocs.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/catalyst_voices_blocs.dart index e21458b916..00c5c6e0cc 100644 --- a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/catalyst_voices_blocs.dart +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/catalyst_voices_blocs.dart @@ -1,4 +1,5 @@ export 'authentication/authentication.dart'; export 'brand/brand.dart'; export 'login/login.dart'; +export 'registration/registration.dart'; export 'session/session.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/controllers/keychain_creation_controller.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/controllers/keychain_creation_controller.dart new file mode 100644 index 0000000000..4a52f4b46b --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/controllers/keychain_creation_controller.dart @@ -0,0 +1,64 @@ +import 'package:catalyst_voices_blocs/src/registration/registration_navigator.dart'; +import 'package:catalyst_voices_blocs/src/registration/registration_state.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; + +abstract interface class KeychainCreationController {} + +final class RegistrationKeychainCreationController + implements + KeychainCreationController, + RegistrationNavigator { + CreateKeychainStage _stage; + + RegistrationKeychainCreationController({ + CreateKeychainStage stage = CreateKeychainStage.splash, + }) : _stage = stage; + + @override + CreateKeychain? nextStep() { + final nextStep = switch (_stage) { + CreateKeychainStage.splash => + const CreateKeychain(stage: CreateKeychainStage.instructions), + CreateKeychainStage.instructions => throw UnimplementedError(), + CreateKeychainStage.seedPhrase => throw UnimplementedError(), + CreateKeychainStage.checkSeedPhraseInstructions => + throw UnimplementedError(), + CreateKeychainStage.checkSeedPhrase => throw UnimplementedError(), + CreateKeychainStage.checkSeedPhraseResult => throw UnimplementedError(), + CreateKeychainStage.unlockPasswordInstructions => + throw UnimplementedError(), + CreateKeychainStage.unlockPasswordCreate => throw UnimplementedError(), + CreateKeychainStage.created => null, + }; + + if (nextStep != null) { + _stage = nextStep.stage; + } + + return nextStep; + } + + @override + CreateKeychain? previousStep() { + final previousStep = switch (_stage) { + CreateKeychainStage.splash => null, + CreateKeychainStage.instructions => + const CreateKeychain(stage: CreateKeychainStage.splash), + CreateKeychainStage.seedPhrase => throw UnimplementedError(), + CreateKeychainStage.checkSeedPhraseInstructions => + throw UnimplementedError(), + CreateKeychainStage.checkSeedPhrase => throw UnimplementedError(), + CreateKeychainStage.checkSeedPhraseResult => throw UnimplementedError(), + CreateKeychainStage.unlockPasswordInstructions => + throw UnimplementedError(), + CreateKeychainStage.unlockPasswordCreate => throw UnimplementedError(), + CreateKeychainStage.created => throw UnimplementedError(), + }; + + if (previousStep != null) { + _stage = previousStep.stage; + } + + return previousStep; + } +} diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/controllers/wallet_link_controller.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/controllers/wallet_link_controller.dart new file mode 100644 index 0000000000..826ad04473 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/controllers/wallet_link_controller.dart @@ -0,0 +1,44 @@ +import 'package:catalyst_voices_blocs/src/registration/registration_navigator.dart'; +import 'package:catalyst_voices_blocs/src/registration/registration_state.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; + +abstract interface class WalletLinkController {} + +final class RegistrationWalletLinkController + implements WalletLinkController, RegistrationNavigator { + WalletLinkStage _stage; + + RegistrationWalletLinkController({ + WalletLinkStage stage = WalletLinkStage.intro, + }) : _stage = stage; + + @override + WalletLink? nextStep() { + final nextStep = switch (_stage) { + WalletLinkStage.intro => + const WalletLink(stage: WalletLinkStage.selectWallet), + WalletLinkStage.selectWallet => null, + }; + + if (nextStep != null) { + _stage = nextStep.stage; + } + + return nextStep; + } + + @override + WalletLink? previousStep() { + final previousStep = switch (_stage) { + WalletLinkStage.intro => null, + WalletLinkStage.selectWallet => + const WalletLink(stage: WalletLinkStage.intro), + }; + + if (previousStep != null) { + _stage = previousStep.stage; + } + + return previousStep; + } +} diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration.dart new file mode 100644 index 0000000000..b22d82608f --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration.dart @@ -0,0 +1,3 @@ +export 'registration_bloc.dart'; +export 'registration_event.dart'; +export 'registration_state.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_bloc.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_bloc.dart new file mode 100644 index 0000000000..f1466c29ef --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_bloc.dart @@ -0,0 +1,104 @@ +import 'package:catalyst_voices_blocs/src/registration/controllers/keychain_creation_controller.dart'; +import 'package:catalyst_voices_blocs/src/registration/controllers/wallet_link_controller.dart'; +import 'package:catalyst_voices_blocs/src/registration/registration_event.dart'; +import 'package:catalyst_voices_blocs/src/registration/registration_state.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Manages the registration state. +final class RegistrationBloc extends Bloc + implements KeychainCreationController, WalletLinkController { + final RegistrationKeychainCreationController _keychainCreationController; + final RegistrationWalletLinkController _walletLinkController; + + RegistrationBloc() + : _keychainCreationController = RegistrationKeychainCreationController(), + _walletLinkController = RegistrationWalletLinkController(), + super(const GetStarted()) { + on(_handleRegistrationEvent); + } + + /// Returns [RegistrationBloc] if found in widget tree. Does not add + /// rebuild dependency when called. + static RegistrationBloc of(BuildContext context) { + return context.read(); + } + + /// Returns [RegistrationBloc] if found in widget tree. Adds rebuild + /// dependency when called so you can not call it in initState. + static RegistrationBloc watch(BuildContext context) { + return context.watch(); + } + + void _handleRegistrationEvent( + RegistrationEvent event, + Emitter emit, + ) { + final nextState = switch (event) { + CreateAccountTypeEvent(:final type) => _createAccountNextStep(type), + NextStepEvent() => _nextStep(), + PreviousStepEvent() => _previousStep(), + }; + + emit(nextState); + } + + RegistrationState _createAccountNextStep(CreateAccountType type) { + return switch (type) { + CreateAccountType.createNew => const CreateKeychain(), + CreateAccountType.recover => const Recover(), + }; + } + + RegistrationState _nextStep() { + /// Nested function. Responsible only for keychain steps logic. + RegistrationState keychainNextStep() { + final nextStep = _keychainCreationController.nextStep(); + + return nextStep ?? const FinishAccountCreation(); + } + + /// Nested function. Responsible only for wallet link steps logic. + RegistrationState walletLinkNextStep() { + final nextStep = _walletLinkController.nextStep(); + + return nextStep ?? state; + } + + return switch (state) { + GetStarted() => throw StateError( + 'GetStarted has two routes that may go to. ' + 'NextStep is not valid here.', + ), + FinishAccountCreation() => throw UnimplementedError(), + Recover() => throw UnimplementedError(), + CreateKeychain() => keychainNextStep(), + WalletLink() => walletLinkNextStep(), + }; + } + + RegistrationState _previousStep() { + /// Nested function. Responsible only for keychain steps logic. + RegistrationState keychainPreviousStep() { + final previousStep = _keychainCreationController.previousStep(); + + return previousStep ?? const GetStarted(); + } + + /// Nested function. Responsible only for wallet link steps logic. + RegistrationState walletLinkPreviousStep() { + final previousStep = _walletLinkController.previousStep(); + + return previousStep ?? const FinishAccountCreation(); + } + + return switch (state) { + GetStarted() => throw StateError('GetStarted is initial step.'), + FinishAccountCreation() => throw UnimplementedError(), + Recover() => throw UnimplementedError(), + CreateKeychain() => keychainPreviousStep(), + WalletLink() => walletLinkPreviousStep(), + }; + } +} diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_event.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_event.dart new file mode 100644 index 0000000000..18ccb57338 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_event.dart @@ -0,0 +1,32 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +/// Describes events that change the registration. +sealed class RegistrationEvent extends Equatable { + const RegistrationEvent(); +} + +final class CreateAccountTypeEvent extends RegistrationEvent { + final CreateAccountType type; + + const CreateAccountTypeEvent({ + required this.type, + }); + + @override + List get props => [type]; +} + +final class NextStepEvent extends RegistrationEvent { + const NextStepEvent(); + + @override + List get props => []; +} + +final class PreviousStepEvent extends RegistrationEvent { + const PreviousStepEvent(); + + @override + List get props => []; +} diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_navigator.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_navigator.dart new file mode 100644 index 0000000000..680bb5e878 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_navigator.dart @@ -0,0 +1,11 @@ +import 'package:catalyst_voices_blocs/src/registration/registration_state.dart'; + +// Note. Maybe make it non null and add hasNextStep / hasPreviousStep +/// Abstraction for navigation between different [RegistrationState] steps. +abstract interface class RegistrationNavigator { + /// Returns null if there is no next step. + T? nextStep(); + + /// Returns null if there is no previous step. + T? previousStep(); +} diff --git a/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_state.dart b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_state.dart new file mode 100644 index 0000000000..c4db67a0a7 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_blocs/lib/src/registration/registration_state.dart @@ -0,0 +1,68 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +/// Determines the state of registration flow. +/// It consists of 4 separate steps +/// - [GetStarted] which is first one. +/// - [FinishAccountCreation] is special step in case of partially created +/// account. +/// - [Recover] when want to start with existing one. +/// - [CreateNew] is entire flow in it self and has two distinguish sub-steps +/// - [CreateKeychain] where user is creating new keychain. +/// - [WalletLink] where user is linking Keychain with wallet. +sealed class RegistrationState extends Equatable { + const RegistrationState(); +} + +/// User decides where to go here [CreateNew] or [Recover] route. +final class GetStarted extends RegistrationState { + const GetStarted(); + + @override + List get props => []; +} + +/// When [CreateKeychain] is completed but [WalletLink] not. +final class FinishAccountCreation extends RegistrationState { + const FinishAccountCreation(); + + @override + List get props => []; +} + +/// User enters existing seed phrase here. +final class Recover extends RegistrationState { + const Recover(); + + @override + List get props => []; +} + +/// Encapsulates entire process of registration. +sealed class CreateNew extends RegistrationState { + const CreateNew(); +} + +/// Building up information for creating new Keychain. +final class CreateKeychain extends CreateNew { + final CreateKeychainStage stage; + + const CreateKeychain({ + this.stage = CreateKeychainStage.splash, + }); + + @override + List get props => [stage]; +} + +/// Linking existing keychain with wallet. +final class WalletLink extends CreateNew { + final WalletLinkStage stage; + + const WalletLink({ + this.stage = WalletLinkStage.intro, + }); + + @override + List get props => [stage]; +} diff --git a/catalyst_voices/packages/catalyst_voices_brands/lib/src/themes/catalyst.dart b/catalyst_voices/packages/catalyst_voices_brands/lib/src/themes/catalyst.dart index 52149626e6..4c4c7efd64 100644 --- a/catalyst_voices/packages/catalyst_voices_brands/lib/src/themes/catalyst.dart +++ b/catalyst_voices/packages/catalyst_voices_brands/lib/src/themes/catalyst.dart @@ -302,6 +302,7 @@ ThemeData _buildThemeData( final textTheme = _buildTextTheme(voicesColorScheme); return ThemeData( + visualDensity: VisualDensity.standard, appBarTheme: AppBarTheme( backgroundColor: voicesColorScheme.onSurfaceNeutralOpaqueLv1, scrolledUnderElevation: 0, @@ -310,9 +311,8 @@ ThemeData _buildThemeData( backgroundColor: voicesColorScheme.onSurfaceNeutralOpaqueLv0, ), dialogTheme: DialogTheme( - // TODO(damian-molinski): themed value needed. - // We don't have it defined yet. - barrierColor: const Color(0x612A3D61), + // N10-38 + barrierColor: const Color(0x212A3D61), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), clipBehavior: Clip.hardEdge, backgroundColor: voicesColorScheme.elevationsOnSurfaceNeutralLv1White, diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart index 1105d7c665..96ed9fe09f 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations.dart @@ -663,6 +663,186 @@ abstract class VoicesLocalizations { /// In en, this message translates to: /// **'Good password strength'** String get goodPasswordStrength; + + /// A button label to select a cardano wallet. + /// + /// In en, this message translates to: + /// **'Choose Cardano Wallet'** + String get chooseCardanoWallet; + + /// A label on a clickable element that can show more content. + /// + /// In en, this message translates to: + /// **'Learn More'** + String get learnMore; + + /// A header in link wallet flow in registration. + /// + /// In en, this message translates to: + /// **'Link keys to your Catalyst Keychain'** + String get walletLink_header; + + /// A subheader in link wallet flow in registration. + /// + /// In en, this message translates to: + /// **'Link your Cardano wallet'** + String get walletLink_subheader; + + /// A title in link wallet flow on intro screen. + /// + /// In en, this message translates to: + /// **'Link Cardano Wallet & Catalyst Roles to you Catalyst Keychain.'** + String get walletLink_intro_title; + + /// A message (content) in link wallet flow on intro screen. + /// + /// In en, this message translates to: + /// **'You\'re almost there! This is the final and most important step in your account setup.\n\nWe\'re going to link a Cardano Wallet to your Catalyst Keychain, so you can start collecting Role Keys.\n\nRole Keys allow you to enter new spaces, discover new ways to participate, and unlock new ways to earn rewards.\n\nWe\'ll start with your Voter Key by default. You can decide to add a Proposer Key and Drep key if you want, or you can always add them later.'** + String get walletLink_intro_content; + + /// No description provided for @accountCreationCreate. + /// + /// In en, this message translates to: + /// **'Create a new 
Catalyst Keychain'** + String get accountCreationCreate; + + /// No description provided for @accountCreationRecover. + /// + /// In en, this message translates to: + /// **'Recover your
Catalyst Keychain'** + String get accountCreationRecover; + + /// Indicates that created keychain will be stored in this device only + /// + /// In en, this message translates to: + /// **'On this device'** + String get accountCreationOnThisDevice; + + /// No description provided for @accountCreationGetStartedTitle. + /// + /// In en, this message translates to: + /// **'Welcome to Catalyst'** + String get accountCreationGetStartedTitle; + + /// No description provided for @accountCreationGetStatedDesc. + /// + /// In en, this message translates to: + /// **'If you already have a Catalyst keychain you can restore it on this device, or you can create a new Catalyst Keychain.'** + String get accountCreationGetStatedDesc; + + /// No description provided for @accountCreationGetStatedWhatNext. + /// + /// In en, this message translates to: + /// **'What do you want to do?'** + String get accountCreationGetStatedWhatNext; + + /// Title of My Account page + /// + /// In en, this message translates to: + /// **'My Account / Profile & Keychain'** + String get myAccountProfileKeychain; + + /// Subtitle of My Account page + /// + /// In en, this message translates to: + /// **'Your Catalyst keychain & role registration'** + String get yourCatalystKeychainAndRoleRegistration; + + /// Tab on My Account page + /// + /// In en, this message translates to: + /// **'Profile & Keychain'** + String get profileAndKeychain; + + /// Action on Catalyst Keychain card + /// + /// In en, this message translates to: + /// **'Remove Keychain'** + String get removeKeychain; + + /// Describes that wallet is connected on Catalyst Keychain card + /// + /// In en, this message translates to: + /// **'Wallet connected'** + String get walletConnected; + + /// Describes roles on Catalyst Keychain card + /// + /// In en, this message translates to: + /// **'Current Role registrations'** + String get currentRoleRegistrations; + + /// Account role + /// + /// In en, this message translates to: + /// **'Voter'** + String get voter; + + /// Account role + /// + /// In en, this message translates to: + /// **'Proposer'** + String get proposer; + + /// Account role + /// + /// In en, this message translates to: + /// **'Drep'** + String get drep; + + /// Related to account role + /// + /// In en, this message translates to: + /// **'Default'** + String get defaultRole; + + /// No description provided for @catalystKeychain. + /// + /// In en, this message translates to: + /// **'Catalyst Keychain'** + String get catalystKeychain; + + /// No description provided for @accountCreationSplashTitle. + /// + /// In en, this message translates to: + /// **'Create your Catalyst Keychain'** + String get accountCreationSplashTitle; + + /// No description provided for @accountCreationSplashMessage. + /// + /// In en, this message translates to: + /// **'Your keychain is your ticket to participate in 
distributed innovation on the global stage. 

Once you have it, you\'ll be able to enter different spaces, discover awesome ideas, and share your feedback to hep improve ideas. 

As you add new keys to your keychain, you\'ll be able to enter new spaces, unlock new rewards opportunities, and have your voice heard in community decisions.'** + String get accountCreationSplashMessage; + + /// No description provided for @accountCreationSplashNextButton. + /// + /// In en, this message translates to: + /// **'Create your Keychain now'** + String get accountCreationSplashNextButton; + + /// No description provided for @accountInstructionsTitle. + /// + /// In en, this message translates to: + /// **'Great! Your Catalyst Keychain 
has been created.'** + String get accountInstructionsTitle; + + /// No description provided for @accountInstructionsMessage. + /// + /// In en, this message translates to: + /// **'On the next screen, you\'re going to see 12 words. 
This is called your \"seed phrase\". 

It\'s like a super secure password that only you know, 
that allows you to prove ownership of your keychain. 

You\'ll use it to login and recover your account on 
different devices, so be sure to put it somewhere safe!\n\nYou need to write this seed phrase down with pen and paper, so get this ready.'** + String get accountInstructionsMessage; + + /// For example in button that goes to next stage of registration + /// + /// In en, this message translates to: + /// **'Next'** + String get next; + + /// For example in button that goes to previous stage of registration + /// + /// In en, this message translates to: + /// **'Back'** + String get back; } class _VoicesLocalizationsDelegate extends LocalizationsDelegate { diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart index b4ae93ae36..4cd757de9e 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_en.dart @@ -345,4 +345,94 @@ class VoicesLocalizationsEn extends VoicesLocalizations { @override String get goodPasswordStrength => 'Good password strength'; + + @override + String get chooseCardanoWallet => 'Choose Cardano Wallet'; + + @override + String get learnMore => 'Learn More'; + + @override + String get walletLink_header => 'Link keys to your Catalyst Keychain'; + + @override + String get walletLink_subheader => 'Link your Cardano wallet'; + + @override + String get walletLink_intro_title => 'Link Cardano Wallet & Catalyst Roles to you Catalyst Keychain.'; + + @override + String get walletLink_intro_content => 'You\'re almost there! This is the final and most important step in your account setup.\n\nWe\'re going to link a Cardano Wallet to your Catalyst Keychain, so you can start collecting Role Keys.\n\nRole Keys allow you to enter new spaces, discover new ways to participate, and unlock new ways to earn rewards.\n\nWe\'ll start with your Voter Key by default. You can decide to add a Proposer Key and Drep key if you want, or you can always add them later.'; + + @override + String get accountCreationCreate => 'Create a new 
Catalyst Keychain'; + + @override + String get accountCreationRecover => 'Recover your
Catalyst Keychain'; + + @override + String get accountCreationOnThisDevice => 'On this device'; + + @override + String get accountCreationGetStartedTitle => 'Welcome to Catalyst'; + + @override + String get accountCreationGetStatedDesc => 'If you already have a Catalyst keychain you can restore it on this device, or you can create a new Catalyst Keychain.'; + + @override + String get accountCreationGetStatedWhatNext => 'What do you want to do?'; + + @override + String get myAccountProfileKeychain => 'My Account / Profile & Keychain'; + + @override + String get yourCatalystKeychainAndRoleRegistration => 'Your Catalyst keychain & role registration'; + + @override + String get profileAndKeychain => 'Profile & Keychain'; + + @override + String get removeKeychain => 'Remove Keychain'; + + @override + String get walletConnected => 'Wallet connected'; + + @override + String get currentRoleRegistrations => 'Current Role registrations'; + + @override + String get voter => 'Voter'; + + @override + String get proposer => 'Proposer'; + + @override + String get drep => 'Drep'; + + @override + String get defaultRole => 'Default'; + + @override + String get catalystKeychain => 'Catalyst Keychain'; + + @override + String get accountCreationSplashTitle => 'Create your Catalyst Keychain'; + + @override + String get accountCreationSplashMessage => 'Your keychain is your ticket to participate in 
distributed innovation on the global stage. 

Once you have it, you\'ll be able to enter different spaces, discover awesome ideas, and share your feedback to hep improve ideas. 

As you add new keys to your keychain, you\'ll be able to enter new spaces, unlock new rewards opportunities, and have your voice heard in community decisions.'; + + @override + String get accountCreationSplashNextButton => 'Create your Keychain now'; + + @override + String get accountInstructionsTitle => 'Great! Your Catalyst Keychain 
has been created.'; + + @override + String get accountInstructionsMessage => 'On the next screen, you\'re going to see 12 words. 
This is called your \"seed phrase\". 

It\'s like a super secure password that only you know, 
that allows you to prove ownership of your keychain. 

You\'ll use it to login and recover your account on 
different devices, so be sure to put it somewhere safe!\n\nYou need to write this seed phrase down with pen and paper, so get this ready.'; + + @override + String get next => 'Next'; + + @override + String get back => 'Back'; } diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart index 734ef69842..338ca7acb5 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/generated/catalyst_voices_localizations_es.dart @@ -345,4 +345,94 @@ class VoicesLocalizationsEs extends VoicesLocalizations { @override String get goodPasswordStrength => 'Good password strength'; + + @override + String get chooseCardanoWallet => 'Choose Cardano Wallet'; + + @override + String get learnMore => 'Learn More'; + + @override + String get walletLink_header => 'Link keys to your Catalyst Keychain'; + + @override + String get walletLink_subheader => 'Link your Cardano wallet'; + + @override + String get walletLink_intro_title => 'Link Cardano Wallet & Catalyst Roles to you Catalyst Keychain.'; + + @override + String get walletLink_intro_content => 'You\'re almost there! This is the final and most important step in your account setup.\n\nWe\'re going to link a Cardano Wallet to your Catalyst Keychain, so you can start collecting Role Keys.\n\nRole Keys allow you to enter new spaces, discover new ways to participate, and unlock new ways to earn rewards.\n\nWe\'ll start with your Voter Key by default. You can decide to add a Proposer Key and Drep key if you want, or you can always add them later.'; + + @override + String get accountCreationCreate => 'Create a new 
Catalyst Keychain'; + + @override + String get accountCreationRecover => 'Recover your
Catalyst Keychain'; + + @override + String get accountCreationOnThisDevice => 'On this device'; + + @override + String get accountCreationGetStartedTitle => 'Welcome to Catalyst'; + + @override + String get accountCreationGetStatedDesc => 'If you already have a Catalyst keychain you can restore it on this device, or you can create a new Catalyst Keychain.'; + + @override + String get accountCreationGetStatedWhatNext => 'What do you want to do?'; + + @override + String get myAccountProfileKeychain => 'My Account / Profile & Keychain'; + + @override + String get yourCatalystKeychainAndRoleRegistration => 'Your Catalyst keychain & role registration'; + + @override + String get profileAndKeychain => 'Profile & Keychain'; + + @override + String get removeKeychain => 'Remove Keychain'; + + @override + String get walletConnected => 'Wallet connected'; + + @override + String get currentRoleRegistrations => 'Current Role registrations'; + + @override + String get voter => 'Voter'; + + @override + String get proposer => 'Proposer'; + + @override + String get drep => 'Drep'; + + @override + String get defaultRole => 'Default'; + + @override + String get catalystKeychain => 'Catalyst Keychain'; + + @override + String get accountCreationSplashTitle => 'Create your Catalyst Keychain'; + + @override + String get accountCreationSplashMessage => 'Your keychain is your ticket to participate in 
distributed innovation on the global stage. 

Once you have it, you\'ll be able to enter different spaces, discover awesome ideas, and share your feedback to hep improve ideas. 

As you add new keys to your keychain, you\'ll be able to enter new spaces, unlock new rewards opportunities, and have your voice heard in community decisions.'; + + @override + String get accountCreationSplashNextButton => 'Create your Keychain now'; + + @override + String get accountInstructionsTitle => 'Great! Your Catalyst Keychain 
has been created.'; + + @override + String get accountInstructionsMessage => 'On the next screen, you\'re going to see 12 words. 
This is called your \"seed phrase\". 

It\'s like a super secure password that only you know, 
that allows you to prove ownership of your keychain. 

You\'ll use it to login and recover your account on 
different devices, so be sure to put it somewhere safe!\n\nYou need to write this seed phrase down with pen and paper, so get this ready.'; + + @override + String get next => 'Next'; + + @override + String get back => 'Back'; } diff --git a/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb b/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb index 65c3750b5b..a97306a76d 100644 --- a/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb +++ b/catalyst_voices/packages/catalyst_voices_localization/lib/l10n/intl_en.arb @@ -425,5 +425,92 @@ "goodPasswordStrength": "Good password strength", "@goodPasswordStrength": { "description": "Describes a password that is strong." + }, + "chooseCardanoWallet": "Choose Cardano Wallet", + "@chooseCardanoWallet": { + "description": "A button label to select a cardano wallet." + }, + "learnMore": "Learn More", + "@learnMore": { + "description": "A label on a clickable element that can show more content." + }, + "walletLink_header": "Link keys to your Catalyst Keychain", + "@walletLink_header": { + "description": "A header in link wallet flow in registration." + }, + "walletLink_subheader": "Link your Cardano wallet", + "@walletLink_subheader": { + "description": "A subheader in link wallet flow in registration." + }, + "walletLink_intro_title": "Link Cardano Wallet & Catalyst Roles to you Catalyst Keychain.", + "@walletLink_intro_title": { + "description": "A title in link wallet flow on intro screen." + }, + "walletLink_intro_content": "You're almost there! This is the final and most important step in your account setup.\n\nWe're going to link a Cardano Wallet to your Catalyst Keychain, so you can start collecting Role Keys.\n\nRole Keys allow you to enter new spaces, discover new ways to participate, and unlock new ways to earn rewards.\n\nWe'll start with your Voter Key by default. You can decide to add a Proposer Key and Drep key if you want, or you can always add them later.", + "@walletLink_intro_content": { + "description": "A message (content) in link wallet flow on intro screen." + }, + "accountCreationCreate": "Create a new \u2028Catalyst Keychain", + "accountCreationRecover": "Recover your\u2028Catalyst Keychain", + "accountCreationOnThisDevice": "On this device", + "@accountCreationOnThisDevice": { + "description": "Indicates that created keychain will be stored in this device only" + }, + "accountCreationGetStartedTitle": "Welcome to Catalyst", + "accountCreationGetStatedDesc": "If you already have a Catalyst keychain you can restore it on this device, or you can create a new Catalyst Keychain.", + "accountCreationGetStatedWhatNext": "What do you want to do?", + "myAccountProfileKeychain": "My Account / Profile & Keychain", + "@myAccountProfileKeychain": { + "description": "Title of My Account page" + }, + "yourCatalystKeychainAndRoleRegistration": "Your Catalyst keychain & role registration", + "@yourCatalystKeychainAndRoleRegistration": { + "description": "Subtitle of My Account page" + }, + "profileAndKeychain": "Profile & Keychain", + "@profileAndKeychain": { + "description": "Tab on My Account page" + }, + "removeKeychain": "Remove Keychain", + "@removeKeychain": { + "description": "Action on Catalyst Keychain card" + }, + "walletConnected": "Wallet connected", + "@walletConnected": { + "description": "Describes that wallet is connected on Catalyst Keychain card" + }, + "currentRoleRegistrations": "Current Role registrations", + "@currentRoleRegistrations": { + "description": "Describes roles on Catalyst Keychain card" + }, + "voter": "Voter", + "@voter": { + "description": "Account role" + }, + "proposer": "Proposer", + "@proposer": { + "description": "Account role" + }, + "drep": "Drep", + "@drep": { + "description": "Account role" + }, + "defaultRole": "Default", + "@defaultRole": { + "description": "Related to account role" + }, + "catalystKeychain": "Catalyst Keychain", + "accountCreationSplashTitle": "Create your Catalyst Keychain", + "accountCreationSplashMessage": "Your keychain is your ticket to participate in \u2028distributed innovation on the global stage. \u2028\u2028Once you have it, you'll be able to enter different spaces, discover awesome ideas, and share your feedback to hep improve ideas. \u2028\u2028As you add new keys to your keychain, you'll be able to enter new spaces, unlock new rewards opportunities, and have your voice heard in community decisions.", + "accountCreationSplashNextButton": "Create your Keychain now", + "accountInstructionsTitle": "Great! Your Catalyst Keychain \u2028has been created.", + "accountInstructionsMessage": "On the next screen, you're going to see 12 words. \u2028This is called your \"seed phrase\". \u2028\u2028It's like a super secure password that only you know, \u2028that allows you to prove ownership of your keychain. \u2028\u2028You'll use it to login and recover your account on \u2028different devices, so be sure to put it somewhere safe!\n\nYou need to write this seed phrase down with pen and paper, so get this ready.", + "next": "Next", + "@next": { + "description": "For example in button that goes to next stage of registration" + }, + "back": "Back", + "@back": { + "description": "For example in button that goes to previous stage of registration" } } \ No newline at end of file diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/account/account_role.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/account/account_role.dart new file mode 100644 index 0000000000..bdeafd803e --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/account/account_role.dart @@ -0,0 +1,5 @@ +enum AccountRole { + voter, + proposer, + drep, +} diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/catalyst_voices_models.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/catalyst_voices_models.dart index 072d1267fe..2cbe5ea91d 100644 --- a/catalyst_voices/packages/catalyst_voices_models/lib/src/catalyst_voices_models.dart +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/catalyst_voices_models.dart @@ -1,11 +1,13 @@ library catalyst_voices_models; +export 'account/account_role.dart'; export 'auth/authentication_status.dart'; export 'auth/password_strength.dart'; export 'errors/errors.dart'; export 'proposal/funded_proposal.dart'; export 'proposal/pending_proposal.dart'; export 'proposal/proposal_status.dart'; +export 'registration/registration.dart'; export 'session_data.dart'; export 'space.dart'; export 'treasury/treasury_campaign_builder.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/registration/create_account_type.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/registration/create_account_type.dart new file mode 100644 index 0000000000..891e4e7e63 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/registration/create_account_type.dart @@ -0,0 +1 @@ +enum CreateAccountType { createNew, recover } diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/registration/create_keychain_stage.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/registration/create_keychain_stage.dart new file mode 100644 index 0000000000..61ca5e435a --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/registration/create_keychain_stage.dart @@ -0,0 +1,12 @@ +/// Describes the keychain creation flow during registration. +enum CreateKeychainStage { + splash, + instructions, + seedPhrase, + checkSeedPhraseInstructions, + checkSeedPhrase, + checkSeedPhraseResult, + unlockPasswordInstructions, + unlockPasswordCreate, + created, +} diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/registration/registration.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/registration/registration.dart new file mode 100644 index 0000000000..67b3eeedfe --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/registration/registration.dart @@ -0,0 +1,3 @@ +export 'create_account_type.dart'; +export 'create_keychain_stage.dart'; +export 'wallet_link_stage.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/registration/wallet_link_stage.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/registration/wallet_link_stage.dart new file mode 100644 index 0000000000..364571f6c1 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/registration/wallet_link_stage.dart @@ -0,0 +1,8 @@ +/// Describes the link wallet flow during registration. +enum WalletLinkStage { + /// The welcome screen for the link wallet flow. + intro, + + /// A screen where the user is asked to connect the cardano wallet. + selectWallet, +} diff --git a/catalyst_voices/packages/catalyst_voices_models/lib/src/seed_phrase.dart b/catalyst_voices/packages/catalyst_voices_models/lib/src/seed_phrase.dart new file mode 100644 index 0000000000..977ee1cdd3 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/lib/src/seed_phrase.dart @@ -0,0 +1,93 @@ +// cspell: words wordlists WORDLIST +// ignore_for_file: implementation_imports + +import 'dart:typed_data'; +import 'package:bip39/bip39.dart' as bip39; +import 'package:bip39/src/wordlists/english.dart'; +import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; +import 'package:convert/convert.dart'; +import 'package:ed25519_hd_key/ed25519_hd_key.dart'; + +/// Represents a seed phrase consisting of a mnemonic and provides methods for +/// generating and deriving cryptographic data from the mnemonic. +/// +/// The `SeedPhrase` class allows creation of a seed phrase either randomly, +/// from a given mnemonic, or from entropy data. It supports converting between +/// different formats, including Uint8List and hex strings. +class SeedPhrase { + /// The mnemonic phrase + final String mnemonic; + + /// Generates a new seed phrase with a random mnemonic. + /// + /// Throws an [ArgumentError] if the word count is invalid. + /// + /// [wordCount]: The number of words in the mnemonic. + /// The default word count is 12, but can specify 12, 15, 18, 21, or 24 words. + /// with a higher word count providing greater entropy and security. + SeedPhrase({int wordCount = 12}) + : this.fromMnemonic( + bip39.generateMnemonic( + strength: (wordCount * 32) ~/ 3, + ), + ); + + /// Creates a SeedPhrase from an existing [Uint8List] entropy. + /// + /// [encodedData]: The entropy data as a Uint8List. + SeedPhrase.fromUint8ListEntropy(Uint8List encodedData) + : this.fromHexEntropy(hex.encode(encodedData)); + + /// Creates a SeedPhrase from an existing hex-encoded entropy. + /// + /// [encodedData]: The entropy data as a hex string. + SeedPhrase.fromHexEntropy(String encodedData) + : this.fromMnemonic(bip39.entropyToMnemonic(encodedData)); + + /// Creates a SeedPhrase from an existing [mnemonic]. + /// + /// Throws an [ArgumentError] if the mnemonic is invalid. + /// + /// [mnemonic]: The mnemonic to derive the seed from. + SeedPhrase.fromMnemonic(this.mnemonic) + : assert(bip39.validateMnemonic(mnemonic), 'Invalid mnemonic phrase'); + + /// The seed derived from the mnemonic as a Uint8List. + Uint8List get uint8ListSeed => bip39.mnemonicToSeed(mnemonic); + + /// The seed derived from the mnemonic as a hex-encoded string. + String get hexSeed => bip39.mnemonicToSeedHex(mnemonic); + + /// The entropy derived from the mnemonic as a Uint8List. + Uint8List get uint8ListEntropy => Uint8List.fromList(hex.decode(hexEntropy)); + + /// The entropy derived from the mnemonic as a hex-encoded string. + String get hexEntropy => bip39.mnemonicToEntropy(mnemonic); + + /// The mnemonic phrase as a list of individual words. + List get mnemonicWords => mnemonic.split(' '); + + /// Derives an Ed25519 key pair from a seed. + /// + /// Throws a [RangeError] If the provided [offset] is negative or exceeds + /// the length of the seed (64). + /// + /// [offset]: The offset is applied + /// to the seed to adjust where key derivation starts. It defaults to 0. + Future deriveKeyPair([int offset = 0]) async { + final modifiedSeed = uint8ListSeed.sublist(offset); + + final masterKey = await ED25519_HD_KEY.getMasterKeyFromSeed(modifiedSeed); + final privateKey = masterKey.key; + + final publicKey = await ED25519_HD_KEY.getPublicKey(privateKey, false); + + return Ed25519KeyPair( + publicKey: Ed25519PublicKey.fromBytes(publicKey), + privateKey: Ed25519PrivateKey.fromBytes(privateKey), + ); + } + + /// The full list of BIP-39 mnemonic words in English. + static List get wordList => WORDLIST; +} diff --git a/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml b/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml index a25c85724b..733581ca59 100644 --- a/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml +++ b/catalyst_voices/packages/catalyst_voices_models/pubspec.yaml @@ -7,7 +7,10 @@ environment: sdk: ">=3.5.0 <4.0.0" dependencies: + bip39: ^1.0.6 catalyst_cardano_serialization: ^0.4.0 + convert: ^3.1.1 + ed25519_hd_key: ^2.3.0 equatable: ^2.0.5 flutter_quill: ^10.5.13 meta: ^1.10.0 diff --git a/catalyst_voices/packages/catalyst_voices_models/test/seed_phrase_test.dart b/catalyst_voices/packages/catalyst_voices_models/test/seed_phrase_test.dart new file mode 100644 index 0000000000..f102708cb1 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_models/test/seed_phrase_test.dart @@ -0,0 +1,92 @@ +import 'package:bip39/bip39.dart' as bip39; +import 'package:catalyst_voices_models/src/seed_phrase.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group(SeedPhrase, () { + test('should generate a new SeedPhrase with random mnemonic', () { + final seedPhrase = SeedPhrase(); + expect(seedPhrase.mnemonic, isNotEmpty); + expect(seedPhrase.uint8ListSeed, isNotEmpty); + expect(seedPhrase.hexSeed, isNotEmpty); + expect(seedPhrase.mnemonicWords.length, 12); + }); + + test('should generate a seed phrase with 12, 15, 18, 21, and 24 words', () { + for (final wordCount in [12, 15, 18, 21, 24]) { + final seedPhrase = SeedPhrase(wordCount: wordCount); + expect(seedPhrase.mnemonicWords.length, wordCount); + expect(bip39.validateMnemonic(seedPhrase.mnemonic), isTrue); + } + }); + + test('should throw an error for an invalid word count', () { + expect(() => SeedPhrase(wordCount: 9), throwsA(isA())); + expect(() => SeedPhrase(wordCount: 13), throwsA(isA())); + expect(() => SeedPhrase(wordCount: 27), throwsA(isA())); + }); + + test('should create SeedPhrase from a valid mnemonic', () { + final validMnemonic = bip39.generateMnemonic(); + final seedPhrase = SeedPhrase.fromMnemonic(validMnemonic); + expect(seedPhrase.mnemonic, validMnemonic); + expect(seedPhrase.hexSeed, bip39.mnemonicToSeedHex(validMnemonic)); + }); + + test('should create SeedPhrase from hex-encoded entropy', () { + final entropy = bip39.mnemonicToEntropy(bip39.generateMnemonic()); + final seedPhrase = SeedPhrase.fromHexEntropy(entropy); + + expect(seedPhrase.mnemonic, isNotEmpty); + expect(seedPhrase.hexEntropy, entropy); + }); + + test('should throw an error for invalid mnemonic', () { + const invalidMnemonic = 'invalid mnemonic phrase'; + expect( + () => SeedPhrase.fromMnemonic(invalidMnemonic), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid mnemonic phrase'), + ), + ), + ); + }); + + test('should contain consistent mnemonic and seed in generated SeedPhrase', + () { + final seedPhrase = SeedPhrase(); + final mnemonic = seedPhrase.mnemonic; + final seed = seedPhrase.hexSeed; + + expect(bip39.mnemonicToSeedHex(mnemonic), seed); + }); + + test('should split mnemonic into a list of words', () { + final mnemonic = bip39.generateMnemonic(); + final seedPhrase = SeedPhrase.fromMnemonic(mnemonic); + final expectedWords = mnemonic.split(' '); + expect(seedPhrase.mnemonicWords, expectedWords); + }); + + test('should generate key pair with different valid offsets', () async { + for (final offset in [0, 4, 28, 32, 64]) { + final keyPair = await SeedPhrase().deriveKeyPair(offset); + + expect(keyPair, isNotNull); + } + }); + + test('should throw an error for key pair with out of range offset', + () async { + for (final offset in [-1, 65]) { + expect( + () async => SeedPhrase().deriveKeyPair(offset), + throwsA(isA()), + ); + } + }); + }); +} diff --git a/catalyst_voices/packages/catalyst_voices_repositories/lib/src/credentials_storage_repository.dart b/catalyst_voices/packages/catalyst_voices_repositories/lib/src/credentials_storage_repository.dart index 6367bd42a5..25beccc74b 100644 --- a/catalyst_voices/packages/catalyst_voices_repositories/lib/src/credentials_storage_repository.dart +++ b/catalyst_voices/packages/catalyst_voices_repositories/lib/src/credentials_storage_repository.dart @@ -9,21 +9,18 @@ import 'package:result_type/result_type.dart'; /// It will be replaced by a proper implementation as soon as authentication /// flow will be defined. final class CredentialsStorageRepository { - final SecureStorageService secureStorageService; + final DummyAuthStorage _storage; - const CredentialsStorageRepository({required this.secureStorageService}); + const CredentialsStorageRepository({ + required DummyAuthStorage storage, + }) : _storage = storage; - void get clearSessionData => secureStorageService.deleteAll; + Future get clearSessionData async => _storage.clear(); Future> getSessionData() async { try { - final email = await secureStorageService.get( - SecureStorageKeysConst.dummyEmail, - ); - - final password = await secureStorageService.get( - SecureStorageKeysConst.dummyPassword, - ); + final email = await _storage.readEmail(); + final password = await _storage.readPassword(); if (email == null || password == null) { return Success(null); @@ -44,15 +41,8 @@ final class CredentialsStorageRepository { SessionData sessionData, ) async { try { - await secureStorageService.set( - SecureStorageKeysConst.dummyEmail, - sessionData.email, - ); - - await secureStorageService.set( - SecureStorageKeysConst.dummyPassword, - sessionData.password, - ); + await _storage.writeEmail(sessionData.email); + await _storage.writePassword(sessionData.password); return Success(null); } on SecureStorageError catch (_) { return Failure(SecureStorageError.canNotSaveData); diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/catalyst_voices_services.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/catalyst_voices_services.dart index b9cfd362c0..c86d5ab5db 100644 --- a/catalyst_voices/packages/catalyst_voices_services/lib/src/catalyst_voices_services.dart +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/catalyst_voices_services.dart @@ -1 +1,7 @@ -export 'secure_storage/secure_storage.dart'; +export 'storage/dummy_auth_storage.dart'; +export 'storage/secure_storage.dart'; +export 'storage/storage.dart'; +export 'storage/vault/lock_factor.dart'; +export 'storage/vault/lock_factor_codec.dart' show LockFactorCodec; +export 'storage/vault/secure_storage_vault.dart'; +export 'storage/vault/vault.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/secure_storage/secure_storage.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/secure_storage/secure_storage.dart deleted file mode 100644 index 37c0fba470..0000000000 --- a/catalyst_voices/packages/catalyst_voices_services/lib/src/secure_storage/secure_storage.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'secure_storage_keys.dart'; -export 'secure_storage_service.dart'; diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/secure_storage/secure_storage_keys.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/secure_storage/secure_storage_keys.dart deleted file mode 100644 index 7a5df87dd4..0000000000 --- a/catalyst_voices/packages/catalyst_voices_services/lib/src/secure_storage/secure_storage_keys.dart +++ /dev/null @@ -1,6 +0,0 @@ -final class SecureStorageKeysConst { - static const dummyEmail = 'email'; - static const dummyPassword = 'password'; - - const SecureStorageKeysConst._(); -} diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/secure_storage/secure_storage_service.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/secure_storage/secure_storage_service.dart deleted file mode 100644 index 6c506911c9..0000000000 --- a/catalyst_voices/packages/catalyst_voices_services/lib/src/secure_storage/secure_storage_service.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -final class SecureStorageService { - final _storage = const FlutterSecureStorage(); - - SecureStorageService(); - - Future get deleteAll async => _storage.deleteAll(); - - Future delete(String key) { - return _storage.delete(key: key); - } - - Future get(String key) async { - final value = await _storage.read(key: key); - return value; - } - - Future set(String key, String value) async { - return _storage.write(key: key, value: value); - } -} diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/dummy_auth_storage.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/dummy_auth_storage.dart new file mode 100644 index 0000000000..74a5674591 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/dummy_auth_storage.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:catalyst_voices_services/src/storage/secure_storage.dart'; + +abstract interface class DummyAuthStorage { + FutureOr readEmail(); + + FutureOr writeEmail(String? value); + + FutureOr readPassword(); + + FutureOr writePassword(String? value); + + Future clear(); +} + +final class SecureDummyAuthStorage extends SecureStorage + implements DummyAuthStorage { + static const _emailKey = 'email'; + static const _passwordKey = 'password'; + + const SecureDummyAuthStorage({ + super.secureStorage, + }); + + @override + FutureOr readEmail() => readString(key: _emailKey); + + @override + FutureOr writeEmail(String? value) { + return writeString(value, key: _emailKey); + } + + @override + FutureOr readPassword() => readString(key: _passwordKey); + + @override + FutureOr writePassword(String? value) { + return writeString(value, key: _passwordKey); + } + + @override + Future clear() async { + await writeEmail(null); + await writePassword(null); + } +} diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/secure_storage.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/secure_storage.dart new file mode 100644 index 0000000000..ca9d3aebbd --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/secure_storage.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:catalyst_voices_services/src/storage/storage.dart'; +import 'package:catalyst_voices_services/src/storage/storage_string_mixin.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +const _keyPrefix = 'SecureStorage'; + +base class SecureStorage with StorageAsStringMixin implements Storage { + final FlutterSecureStorage _secureStorage; + + const SecureStorage({ + FlutterSecureStorage secureStorage = const FlutterSecureStorage(), + }) : _secureStorage = secureStorage; + + @override + Future readString({required String key}) { + final effectiveKey = _buildVaultKey(key); + + return _secureStorage.read(key: effectiveKey); + } + + @override + Future writeString( + String? value, { + required String key, + }) async { + final effectiveKey = _buildVaultKey(key); + + if (value != null) { + await _secureStorage.write(key: effectiveKey, value: value); + } else { + await _secureStorage.delete(key: effectiveKey); + } + } + + @override + FutureOr clear() async { + final all = await _secureStorage.readAll(); + final vaultKeys = List.of(all.keys).where((e) => e.startsWith(_keyPrefix)); + + for (final key in vaultKeys) { + await _secureStorage.delete(key: key); + } + } + + String _buildVaultKey(String key) { + return '$_keyPrefix.$key'; + } +} diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/storage.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/storage.dart new file mode 100644 index 0000000000..7e602b3705 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/storage.dart @@ -0,0 +1,45 @@ +//ignore_for_file: avoid_positional_boolean_parameters + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +/// Abstract representation of generic storage. This interface does +/// not determinate where data should be stored or how it should be stored. +/// Encrypted or not. +/// +/// Implementation may use local memory / filesystem or shared preferences or +/// any other. +abstract interface class Storage { + FutureOr readString({required String key}); + + FutureOr writeString( + String? value, { + required String key, + }); + + FutureOr readInt({required String key}); + + FutureOr writeInt( + int? value, { + required String key, + }); + + FutureOr readBool({required String key}); + + FutureOr writeBool( + bool? value, { + required String key, + }); + + FutureOr readBytes({required String key}); + + FutureOr writeBytes( + Uint8List? value, { + required String key, + }); + + FutureOr delete({required String key}); + + FutureOr clear(); +} diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/storage_string_mixin.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/storage_string_mixin.dart new file mode 100644 index 0000000000..ccb19f245d --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/storage_string_mixin.dart @@ -0,0 +1,76 @@ +//ignore_for_file: avoid_positional_boolean_parameters + +import 'dart:async'; +import 'dart:convert'; + +import 'package:catalyst_voices_services/src/storage/storage.dart'; +import 'package:catalyst_voices_services/src/storage/vault/secure_storage_vault.dart'; +import 'package:flutter/foundation.dart'; + +/// Utility mixin which implements all but String read/write of [Storage] +/// interface. Every method is has its mapping to [readString]/[writeString]. +/// +/// See [SecureStorageVault] as example. +mixin StorageAsStringMixin implements Storage { + @override + FutureOr readInt({required String key}) async { + final value = await readString(key: key); + return value != null ? int.parse(value) : null; + } + + @override + FutureOr writeInt( + int? value, { + required String key, + }) { + return writeString(value?.toString(), key: key); + } + + @override + FutureOr readBool({required String key}) async { + final value = await readInt(key: key); + + return switch (value) { + 0 => false, + 1 => true, + _ => null, + }; + } + + @override + FutureOr writeBool( + bool? value, { + required String key, + }) { + final asInt = value != null + ? value + ? 1 + : 0 + : null; + + return writeInt(asInt, key: key); + } + + @override + FutureOr readBytes({required String key}) async { + final base64String = await readString(key: key); + final bytes = base64String != null + ? Uint8List.fromList(base64Decode(base64String)) + : null; + + return bytes; + } + + @override + FutureOr writeBytes( + Uint8List? value, { + required String key, + }) { + final base64String = value != null ? base64Encode(value) : null; + + return writeString(base64String, key: key); + } + + @override + FutureOr delete({required String key}) => writeString(null, key: key); +} diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/lock_factor.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/lock_factor.dart new file mode 100644 index 0000000000..905e44a570 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/lock_factor.dart @@ -0,0 +1,87 @@ +import 'package:catalyst_voices_services/src/storage/vault/vault.dart'; + +enum _LockFactorType { voidFactor, password } + +// Note. +// In future we may add MultiLockFactor for bio and password unlock factors + +/// Abstract representation of different factors that can lock [Vault] with. +/// +/// Most common is [PasswordLockFactor] which can be use as standalone factor. +/// +/// This class is serializable to/from json. +sealed class LockFactor { + /// Use [LockFactor.toJson] as parameter for this factory. + factory LockFactor.fromJson(Map json) { + final typeName = json['type']; + final type = _LockFactorType.values.asNameMap()[typeName]; + + return switch (type) { + _LockFactorType.voidFactor => const VoidLockFactor(), + _LockFactorType.password => PasswordLockFactor.fromJson(json), + null => throw ArgumentError('Unknown type name($typeName)', 'json'), + }; + } + + /// Returns true when this [LockFactor] can be used to unlock + /// other [LockFactor]. + bool unlocks(LockFactor factor); + + /// Returns json representation on this [LockFactor]. + /// + /// Should be used with [LockFactor.fromJson]. + Map toJson(); +} + +/// Can not be used to unlock anything. Useful as default value for [LockFactor] +/// variables. +/// +/// [unlocks] always returns false. +final class VoidLockFactor implements LockFactor { + const VoidLockFactor(); + + @override + bool unlocks(LockFactor factor) => false; + + @override + Map toJson() { + return { + 'type': _LockFactorType.voidFactor.name, + }; + } + + @override + String toString() => 'VoidLockFactor'; +} + +/// Password matching [LockFactor]. +/// +/// Only unlocks other [PasswordLockFactor] with matching +/// [PasswordLockFactor._data]. +final class PasswordLockFactor implements LockFactor { + final String _data; + + const PasswordLockFactor(this._data); + + factory PasswordLockFactor.fromJson(Map json) { + return PasswordLockFactor( + json['data'] as String, + ); + } + + @override + bool unlocks(LockFactor factor) { + return factor is PasswordLockFactor && _data == factor._data; + } + + @override + Map toJson() { + return { + 'type': _LockFactorType.password.name, + 'data': _data, + }; + } + + @override + String toString() => 'PasswordLockFactor(data=****)'; +} diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/lock_factor_codec.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/lock_factor_codec.dart new file mode 100644 index 0000000000..c6754ed75e --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/lock_factor_codec.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +import 'package:catalyst_voices_services/src/storage/vault/lock_factor.dart'; + +abstract class LockFactorCodec extends Codec { + const LockFactorCodec(); +} + +/// Uses [LockFactor.toJson] and [LockFactor.fromJson] to serialize to +/// [String] using [json]. +class DefaultLockFactorCodec extends LockFactorCodec { + const DefaultLockFactorCodec(); + + @override + Converter get decoder => const _LockFactorDecoder(); + + @override + Converter get encoder => const _LockFactorEncoder(); +} + +class _LockFactorDecoder extends Converter { + const _LockFactorDecoder(); + + @override + LockFactor convert(String input) { + final json = jsonDecode(input) as Map; + + return LockFactor.fromJson(json); + } +} + +class _LockFactorEncoder extends Converter { + const _LockFactorEncoder(); + + @override + String convert(LockFactor input) { + final json = input.toJson(); + + return jsonEncode(json); + } +} diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart new file mode 100644 index 0000000000..024b3ebac0 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/secure_storage_vault.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:catalyst_voices_services/src/storage/storage_string_mixin.dart'; +import 'package:catalyst_voices_services/src/storage/vault/lock_factor.dart'; +import 'package:catalyst_voices_services/src/storage/vault/lock_factor_codec.dart'; +import 'package:catalyst_voices_services/src/storage/vault/vault.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +const _keyPrefix = 'SecureStorageVault'; +const _lockKey = 'LockFactorKey'; +const _unlockKey = 'UnlockFactorKey'; + +// TODO(damian-molinski): Maybe we'll need to encrypt data with LockFactor +/// Implementation of [Vault] that uses [FlutterSecureStorage] as +/// facade for read/write operations. +base class SecureStorageVault with StorageAsStringMixin implements Vault { + final FlutterSecureStorage _secureStorage; + final LockFactorCodec _lockCodec; + + const SecureStorageVault({ + FlutterSecureStorage secureStorage = const FlutterSecureStorage(), + LockFactorCodec lockCodec = const DefaultLockFactorCodec(), + }) : _secureStorage = secureStorage, + _lockCodec = lockCodec; + + /// If storage does not have [LockFactor] this getter will + /// return [VoidLockFactor] as fallback. + Future get _lock => _readLock(_lockKey); + + /// If storage does not have [LockFactor] this getter will + /// return [VoidLockFactor] as fallback. + Future get _unlock => _readLock(_unlockKey); + + @override + Future get isUnlocked async { + final lock = await _lock; + final unlock = await _unlock; + + return unlock.unlocks(lock); + } + + @override + Future lock() => _writeLock(null, key: _unlockKey); + + @override + Future unlock(LockFactor unlock) async { + await _writeLock(unlock, key: _unlockKey); + + return isUnlocked; + } + + @override + Future setLock(LockFactor lock) { + return _writeLock(lock, key: _lockKey); + } + + @override + Future readString({required String key}) => _guardedRead(key: key); + + @override + Future writeString( + String? value, { + required String key, + }) { + return _guardedWrite(value, key: key); + } + + @override + Future clear() async { + final all = await _secureStorage.readAll(); + final vaultKeys = List.of(all.keys).where((e) => e.startsWith(_keyPrefix)); + + for (final key in vaultKeys) { + await _secureStorage.delete(key: key); + } + } + + Future _writeLock( + LockFactor? lock, { + required String key, + }) { + final encodedLock = lock != null ? _lockCodec.encode(lock) : null; + + return _guardedWrite( + encodedLock, + key: key, + requireUnlocked: false, + ); + } + + Future _readLock(String key) async { + final value = await _guardedRead(key: key, requireUnlocked: false); + + return value != null ? _lockCodec.decode(value) : const VoidLockFactor(); + } + + /// Allows operation only when [isUnlocked] it true, otherwise non op. + /// + /// * When [value] is non null writes it to [key]. + /// * When [value] is null then [key] value is deleted. + Future _guardedWrite( + String? value, { + required String key, + bool requireUnlocked = true, + }) async { + final hasAccess = !requireUnlocked || await isUnlocked; + if (!hasAccess) { + return; + } + + final effectiveKey = _buildVaultKey(key); + + if (value != null) { + await _secureStorage.write(key: effectiveKey, value: value); + } else { + await _secureStorage.delete(key: effectiveKey); + } + } + + /// Allows operation only when [isUnlocked] it true, otherwise returns null. + /// + /// Returns value assigned to [key]. May return null if non found for [key]. + Future _guardedRead({ + required String key, + bool requireUnlocked = true, + }) async { + final hasAccess = !requireUnlocked || await isUnlocked; + if (!hasAccess) { + return null; + } + + final effectiveKey = _buildVaultKey(key); + + return _secureStorage.read(key: effectiveKey); + } + + String _buildVaultKey(String key) { + return '$_keyPrefix.$key'; + } +} diff --git a/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/vault.dart b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/vault.dart new file mode 100644 index 0000000000..35717e704b --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/lib/src/storage/vault/vault.dart @@ -0,0 +1,25 @@ +import 'package:catalyst_voices_services/src/storage/storage.dart'; +import 'package:catalyst_voices_services/src/storage/vault/lock_factor.dart'; + +/// Secure version of [Storage] where any read/write methods can take +/// effect only when [isUnlocked] returns true. +/// +/// In order to unlock [Vault] sufficient [LockFactor] have to be +/// set via [unlock] that can unlock [LockFactor] from [setLock]. +/// +/// See [LockFactor.unlocks] for more details. +abstract interface class Vault implements Storage { + /// Returns true when have sufficient [LockFactor] from [unlock]. + Future get isUnlocked; + + /// Deletes unlockFactor if have any. + Future lock(); + + /// Changes [isUnlocked] when [unlock] can unlock [LockFactor] + /// from [setLock]. + Future unlock(LockFactor unlock); + + /// Sets [LockFactor] that which prevents read/write on this [Vault] + /// unless unlocked with matching [LockFactor] via [unlock]. + Future setLock(LockFactor lock); +} diff --git a/catalyst_voices/packages/catalyst_voices_services/pubspec.yaml b/catalyst_voices/packages/catalyst_voices_services/pubspec.yaml index 427e6268e9..45a2d329fe 100644 --- a/catalyst_voices/packages/catalyst_voices_services/pubspec.yaml +++ b/catalyst_voices/packages/catalyst_voices_services/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: chopper: ^7.2.0 flutter: sdk: flutter - flutter_secure_storage: ^9.0.0 + flutter_secure_storage: ^9.2.2 json_annotation: ^4.8.1 rxdart: ^0.27.7 diff --git a/catalyst_voices/packages/catalyst_voices_services/test/src/storage/secure_storage_test.dart b/catalyst_voices/packages/catalyst_voices_services/test/src/storage/secure_storage_test.dart new file mode 100644 index 0000000000..72c8921b2d --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/test/src/storage/secure_storage_test.dart @@ -0,0 +1,80 @@ +import 'package:catalyst_voices_services/src/storage/secure_storage.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:test/test.dart'; + +void main() { + late final FlutterSecureStorage flutterSecureStorage; + late final SecureStorage secureStorage; + + setUpAll(() { + FlutterSecureStorage.setMockInitialValues({}); + + flutterSecureStorage = const FlutterSecureStorage(); + secureStorage = SecureStorage(secureStorage: flutterSecureStorage); + }); + + tearDown(() async { + await secureStorage.clear(); + }); + + test('read returns null when no value found for key', () async { + // Given + const key = 'key'; + + // When + final value = await secureStorage.readString(key: key); + + // Then + expect(value, isNull); + }); + + test('read returns stored value when has one', () async { + // Given + const key = 'key'; + const expectedValue = 'qqqq'; + + // When + await secureStorage.writeString(expectedValue, key: key); + final value = await secureStorage.readString(key: key); + + // Then + expect(value, expectedValue); + }); + + test('writing null deletes value', () async { + // Given + const key = 'key'; + const expectedValue = 'qqqq'; + + // When + await secureStorage.writeString(expectedValue, key: key); + await secureStorage.writeString(null, key: key); + final value = await secureStorage.readString(key: key); + + // Then + expect(value, isNull); + }); + + test('clear removes all values for this storage', () async { + // Given + const keyValues = { + 'one': 'qqq', + 'two': 'qqq', + }; + + // When + for (final entity in keyValues.entries) { + await secureStorage.writeString(entity.value, key: entity.key); + } + + await secureStorage.clear(); + + final futures = + keyValues.keys.map((e) => secureStorage.readString(key: e)).toList(); + + final values = await Future.wait(futures); + + // Then + expect(values, everyElement(isNull)); + }); +} diff --git a/catalyst_voices/packages/catalyst_voices_services/test/src/storage/storage_string_mixin_test.dart b/catalyst_voices/packages/catalyst_voices_services/test/src/storage/storage_string_mixin_test.dart new file mode 100644 index 0000000000..8792e0243f --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/test/src/storage/storage_string_mixin_test.dart @@ -0,0 +1,162 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:catalyst_voices_services/src/storage/storage.dart'; +import 'package:catalyst_voices_services/src/storage/storage_string_mixin.dart'; +import 'package:test/test.dart'; + +void main() { + const key = 'key'; + + final storage = _TestStorage(); + + setUp(storage.clear); + + group('int', () { + test('read returns null when no value found', () async { + // Given + + // When + final value = await storage.readInt(key: key); + + // Then + expect(value, isNull); + }); + + test('read returns non-null when value found', () async { + // Given + const expected = 1; + + // When + storage._data[key] = '$expected'; + final value = await storage.readInt(key: key); + + // Then + expect(value, expected); + }); + + test('internally keeps correct String', () async { + // Given + const expected = 1; + storage._data[key] = '$expected'; + + // When + await storage.writeInt(expected, key: key); + final value = storage.readString(key: key); + + // Then + expect(value, '$expected'); + }); + }); + + group('bool', () { + test('read returns null when no value found', () async { + // Given + + // When + final value = await storage.readBool(key: key); + + // Then + expect(value, isNull); + }); + + test('read stores false as 0', () async { + // Given + const expected = false; + const expectedString = '0'; + + // When + await storage.writeBool(expected, key: key); + final value = await storage.readString(key: key); + + // Then + expect(value, expectedString); + }); + + test('read stores true as 1', () async { + // Given + const expected = true; + const expectedString = '1'; + + // When + await storage.writeBool(expected, key: key); + final value = await storage.readString(key: key); + + // Then + expect(value, expectedString); + }); + + test('write and read values matches', () async { + // Given + const expected = true; + + // When + await storage.writeBool(expected, key: key); + final value = await storage.readBool(key: key); + + // Then + expect(value, expected); + }); + }); + + group('bytes', () { + test('read returns null when no value found', () async { + // Given + + // When + final value = await storage.readBytes(key: key); + + // Then + expect(value, isNull); + }); + + test('can write and read value correctly', () async { + // Given + final bytes = Uint8List.fromList([0, 0, 0, 0, 0, 1]); + + // When + await storage.writeBytes(bytes, key: key); + final value = await storage.readBytes(key: key); + + // Then + expect(value, bytes); + }); + }); + + test('delete writes null string', () async { + // Given + const randomValue = 'D'; + + // When + await storage.writeString(randomValue, key: key); + await storage.delete(key: key); + final value = await storage.readString(key: key); + + // Then + expect(value, isNull); + }); +} + +class _TestStorage with StorageAsStringMixin implements Storage { + final _data = {}; + + @override + FutureOr clear() { + _data.clear(); + } + + @override + FutureOr readString({required String key}) => _data[key]; + + @override + FutureOr writeString( + String? value, { + required String key, + }) { + if (value != null) { + _data[key] = value; + } else { + _data.remove(key); + } + } +} diff --git a/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/lock_factor_codec_test.dart b/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/lock_factor_codec_test.dart new file mode 100644 index 0000000000..d4857ba8eb --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/lock_factor_codec_test.dart @@ -0,0 +1,20 @@ +import 'package:catalyst_voices_services/catalyst_voices_services.dart'; +import 'package:catalyst_voices_services/src/storage/vault/lock_factor_codec.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +void main() { + test('encoding and decoding results in same lock factor', () { + // Given + const lock = PasswordLockFactor('pass1234'); + const LockFactorCodec codec = DefaultLockFactorCodec(); + + // When + final encoded = codec.encoder.convert(lock); + final decoded = codec.decoder.convert(encoded); + + // Then + expect(decoded, isA()); + expect(decoded.unlocks(lock), isTrue); + }); +} diff --git a/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/lock_factor_test.dart b/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/lock_factor_test.dart new file mode 100644 index 0000000000..3faf9efa23 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/lock_factor_test.dart @@ -0,0 +1,132 @@ +import 'package:catalyst_voices_services/catalyst_voices_services.dart'; +import 'package:test/test.dart'; + +void main() { + group('LockFactor', () { + test('void lock serialization does work', () { + // Given + const lock = VoidLockFactor(); + + // When + final json = lock.toJson(); + final deserializedFactor = LockFactor.fromJson(json); + + // Then + expect(deserializedFactor, isA()); + }); + + test('description', () { + // Given + const lock = PasswordLockFactor('pass1234'); + + // When + final json = lock.toJson(); + final deserializedFactor = LockFactor.fromJson(json); + + // Then + expect(deserializedFactor, isA()); + expect(deserializedFactor.unlocks(lock), isTrue); + }); + }); + + group('VoidLockFactor', () { + test('does not unlocks any other lock', () { + // Given + const lock = VoidLockFactor(); + const locks = [ + VoidLockFactor(), + PasswordLockFactor('pass1234'), + ]; + + // When + final unlocks = locks.map((e) => lock.unlocks(e)).toList(); + + // Then + expect(unlocks, everyElement(false)); + }); + + test('toJson result has type field', () { + // Given + const lock = VoidLockFactor(); + + // When + final json = lock.toJson(); + + // Then + expect(json.containsKey('type'), isTrue); + }); + + test('toString equals class name', () { + // Given + const lock = VoidLockFactor(); + + // When + final asString = lock.toString(); + + // Then + expect(asString, lock.runtimeType.toString()); + }); + }); + + group('PasswordLockFactor', () { + test('unlocks other PasswordLockFactor with matching data', () { + // Given + const lock = PasswordLockFactor('admin1234'); + const otherLock = PasswordLockFactor('admin1234'); + + // When + final unlocks = lock.unlocks(otherLock); + + // Then + expect(unlocks, isTrue); + }); + + test('does not unlocks other PasswordLockFactor with different data', () { + // Given + const lock = PasswordLockFactor('admin1234'); + const otherLock = PasswordLockFactor('pass1234'); + + // When + final unlocks = lock.unlocks(otherLock); + + // Then + expect(unlocks, isFalse); + }); + + test('does not unlocks other non PasswordLockFactor', () { + // Given + const lock = PasswordLockFactor('admin1234'); + const otherLock = VoidLockFactor(); + + // When + final unlocks = lock.unlocks(otherLock); + + // Then + expect(unlocks, isFalse); + }); + + test('toJson result has type and data field', () { + // Given + const lock = PasswordLockFactor('admin1234'); + + // When + final json = lock.toJson(); + + // Then + expect(json.containsKey('type'), isTrue); + expect(json.containsKey('data'), isTrue); + }); + + test('toString does not contain password', () { + // Given + const password = 'admin1234'; + const lock = PasswordLockFactor(password); + + // When + final asString = lock.toString(); + + // Then + expect(asString, isNot(contains(password))); + }); + }); +} diff --git a/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/secure_storage_vault_test.dart b/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/secure_storage_vault_test.dart new file mode 100644 index 0000000000..cfba41eb73 --- /dev/null +++ b/catalyst_voices/packages/catalyst_voices_services/test/src/storage/vault/secure_storage_vault_test.dart @@ -0,0 +1,125 @@ +import 'package:catalyst_voices_services/src/storage/vault/lock_factor.dart'; +import 'package:catalyst_voices_services/src/storage/vault/secure_storage_vault.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:test/test.dart'; + +void main() { + late final FlutterSecureStorage flutterSecureStorage; + late final SecureStorageVault vault; + + setUpAll(() { + FlutterSecureStorage.setMockInitialValues({}); + + flutterSecureStorage = const FlutterSecureStorage(); + vault = SecureStorageVault(secureStorage: flutterSecureStorage); + }); + + tearDown(() async { + await flutterSecureStorage.deleteAll(); + }); + + test('lock and unlock factor fallbacks to lock state', () async { + // Given + + // When + final isUnlocked = await vault.isUnlocked; + + // Then + expect(isUnlocked, isFalse); + }); + + test('read returns null when not unlocked', () async { + // Given + const key = 'SecureStorageVault.key'; + const value = 'username'; + + // When + await flutterSecureStorage.write(key: key, value: value); + final readValue = await vault.readString(key: key); + + // Then + expect(readValue, isNull); + }); + + test('write wont happen when is locked', () async { + // Given + const key = 'key'; + const fKey = 'SecureStorageVault.$key'; + const value = 'username'; + + // When + await vault.writeString(value, key: key); + final readValue = await flutterSecureStorage.read(key: fKey); + + // Then + expect(readValue, isNull); + }); + + test('unlock update lock and returns null when locked', () async { + // Given + const lock = PasswordLockFactor('pass1234'); + const key = 'key'; + const value = 'username'; + + // When + await vault.setLock(lock); + final isUnlocked = await vault.unlock(lock); + await vault.writeString(value, key: key); + final readValue = await vault.readString(key: key); + + // Then + expect(isUnlocked, isTrue); + expect(readValue, value); + }); + + test('lock makes vault locked', () async { + // Given + const lock = PasswordLockFactor('pass1234'); + + // When + await vault.setLock(lock); + await vault.unlock(lock); + await vault.lock(); + + final isUnlocked = await vault.isUnlocked; + + // Then + expect(isUnlocked, isFalse); + }); + + test('clear removes all vault keys', () async { + // Given + const lock = PasswordLockFactor('pass1234'); + const vaultKeyValues = { + 'one': 'qqq', + 'two': 'qqq', + }; + const nonVaultKeyValues = { + 'three': 'qqq', + }; + + // When + await vault.setLock(lock); + await vault.unlock(lock); + + for (final entity in vaultKeyValues.entries) { + await vault.writeString(entity.value, key: entity.key); + } + + for (final entity in nonVaultKeyValues.entries) { + await flutterSecureStorage.write(key: entity.key, value: entity.value); + } + + await vault.clear(); + + final futures = + vaultKeyValues.keys.map((e) => vault.readString(key: e)).toList(); + + final values = await Future.wait(futures); + final fValues = await flutterSecureStorage.readAll(); + + // Then + expect(values, everyElement(isNull)); + expect(fValues, nonVaultKeyValues); + }); +} diff --git a/catalyst_voices/uikit_example/Earthfile b/catalyst_voices/uikit_example/Earthfile index 309d129344..dd7eec8dda 100644 --- a/catalyst_voices/uikit_example/Earthfile +++ b/catalyst_voices/uikit_example/Earthfile @@ -1,7 +1,7 @@ VERSION 0.8 IMPORT ../ AS catalyst-voices -IMPORT github.com/input-output-hk/catalyst-ci/earthly/flutter:v3.2.04 AS flutter-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/flutter:v3.2.07 AS flutter-ci # local-build-web - build web version of UIKit example. # Prefixed by "local" to make sure it's not auto triggered, the target was diff --git a/catalyst_voices/uikit_example/lib/examples/voices_tabs_example.dart b/catalyst_voices/uikit_example/lib/examples/voices_tabs_example.dart index d6147faf0b..546c726a96 100644 --- a/catalyst_voices/uikit_example/lib/examples/voices_tabs_example.dart +++ b/catalyst_voices/uikit_example/lib/examples/voices_tabs_example.dart @@ -15,6 +15,7 @@ class VoicesTabsExample extends StatelessWidget { child: Column( children: [ const TabBar( + isScrollable: true, tabs: [ Tab(text: 'Sections'), Tab(text: 'Comments'), diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/README.md b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/README.md index b089a8e850..3a1ec3c2ff 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/README.md +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/README.md @@ -101,13 +101,14 @@ Future main() async { } Transaction _buildUnsignedTx({ - required List utxos, + required Set utxos, required ShelleyAddress changeAddress, }) { const txBuilderConfig = TransactionBuilderConfig( - feeAlgo: LinearFee( - constant: Coin(155381), - coefficient: Coin(44), + feeAlgo: TieredFee( + constant: 155381, + coefficient: 44, + refScriptByteCost: 15, ), maxTxSize: 16384, maxValueSize: 5000, @@ -122,7 +123,7 @@ Transaction _buildUnsignedTx({ final txOutput = TransactionOutput( address: preprodFaucetAddress, - amount: const Value(coin: Coin(1000000)), + amount: const Balance(coin: Coin(1000000)), ); final txBuilder = TransactionBuilder( diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/lib/main.dart b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/lib/main.dart index a3fd2f5c2c..0e99f9d7d3 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/lib/main.dart +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/lib/main.dart @@ -238,7 +238,7 @@ class _WalletDetailsState extends State<_WalletDetails> { List? _rewardAddresses; List? _unusedAddresses; List? _usedAddresses; - List? _utxos; + Set? _utxos; PubDRepKey? _pubDRepKey; List? _registeredPubStakeKeys; List? _unregisteredPubStakeKeys; @@ -450,7 +450,7 @@ String _formatBalance(Balance? balance) { return buffer.toString(); } -String _formatUtxos(List? utxos) { +String _formatUtxos(Set? utxos) { if (utxos == null) { return '---'; } diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart index 5ad2c02bd6..ac34dc1ab6 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_rbac_tx.dart @@ -75,7 +75,7 @@ Future _signAndSubmitRbacTx({ } Future> _buildMetadataEnvelope({ - required List utxos, + required Set utxos, required ShelleyAddress rewardAddress, }) async { final seed = List.generate( @@ -152,7 +152,7 @@ Future> _buildMetadataEnvelope({ } Transaction _buildUnsignedRbacTx({ - required List inputs, + required Set inputs, required ShelleyAddress changeAddress, required ShelleyAddress rewardAddress, required AuxiliaryData auxiliaryData, diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_tx.dart b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_tx.dart index 47a07f8fd2..a92f886e61 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_tx.dart +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/example/lib/sign_and_submit_tx.dart @@ -47,7 +47,7 @@ Future _signAndSubmitTx({ } Transaction _buildUnsignedTx({ - required List utxos, + required Set utxos, required ShelleyAddress changeAddress, }) { /* cSpell:disable */ @@ -81,9 +81,10 @@ Transaction _buildUnsignedTx({ TransactionBuilderConfig _buildTransactionBuilderConfig() { return const TransactionBuilderConfig( - feeAlgo: LinearFee( - constant: Coin(155381), - coefficient: Coin(44), + feeAlgo: TieredFee( + constant: 155381, + coefficient: 44, + refScriptByteCost: 15, ), maxTxSize: 16384, maxValueSize: 5000, diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/blueprint.cue b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/blueprint.cue new file mode 100644 index 0000000000..cb4463fa63 --- /dev/null +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/blueprint.cue @@ -0,0 +1,2 @@ +version: "1.0.0" +project: name: "voices-wallet-automation-test" diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/Earthfile b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/Earthfile similarity index 92% rename from catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/Earthfile rename to catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/Earthfile index ffa8a64a0e..68ae4dc003 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/Earthfile +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/Earthfile @@ -1,5 +1,5 @@ VERSION 0.8 -IMPORT github.com/input-output-hk/catalyst-ci/earthly/flutter:v3.2.04 AS flutter-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/flutter:v3.2.07 AS flutter-ci deps: FROM mcr.microsoft.com/playwright:v1.45.2-jammy @@ -35,7 +35,7 @@ package-test: builder: DO flutter-ci+SETUP - COPY ../../../../../+repo-catalyst-voices-all/repo . + COPY ../../../../+repo-catalyst-voices-all/repo . DO flutter-ci+BOOTSTRAP build-web: @@ -45,13 +45,13 @@ build-web: SAVE ARTIFACT web package-app: - FROM nginx:alpine3.18 + FROM nginx:alpine3.20-slim ARG tag='latest' COPY +build-web/web /usr/share/nginx/html/ SAVE IMAGE test-app:$tag nightly-test: - FROM earthly/dind:alpine-3.19 + FROM earthly/alpine-3.20-docker-26.1.5-r0 COPY compose.yml . WITH DOCKER \ --compose compose.yml \ diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/compose.yml b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/compose.yml similarity index 100% rename from catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/compose.yml rename to catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/compose.yml diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/global-setup.ts b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/global-setup.ts similarity index 100% rename from catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/global-setup.ts rename to catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/global-setup.ts diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/keys.txt b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/keys.txt similarity index 100% rename from catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/keys.txt rename to catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/keys.txt diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/package-lock.json b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/package-lock.json similarity index 100% rename from catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/package-lock.json rename to catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/package-lock.json diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/package.json b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/package.json similarity index 100% rename from catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/package.json rename to catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/package.json diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/playwright.config.ts b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/playwright.config.ts similarity index 100% rename from catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/playwright.config.ts rename to catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/playwright.config.ts diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/utils.ts b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/utils.ts similarity index 100% rename from catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/utils.ts rename to catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/utils.ts diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/wallet-tests.spec.ts b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/wallet-tests.spec.ts similarity index 100% rename from catalyst_voices_packages/catalyst_cardano/catalyst_cardano/test/wallet-automation/wallet-tests.spec.ts rename to catalyst_voices_packages/catalyst_cardano/catalyst_cardano/wallet-automation/wallet-tests.spec.ts diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/src/cardano_wallet.dart b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/src/cardano_wallet.dart index 46ad60abd7..5ae71169b5 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/src/cardano_wallet.dart +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_platform_interface/lib/src/cardano_wallet.dart @@ -116,7 +116,7 @@ abstract interface class CardanoWalletApi { /// specified in amount, and if this cannot be attained, /// null shall be returned. The results can be further paginated by /// [paginate] if it is not null. - Future> getUtxos({ + Future> getUtxos({ Balance? amount, Paginate? paginate, }); diff --git a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/lib/src/interop/catalyst_cardano_wallet_proxy.dart b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/lib/src/interop/catalyst_cardano_wallet_proxy.dart index 77f1ca0b18..025232180f 100644 --- a/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/lib/src/interop/catalyst_cardano_wallet_proxy.dart +++ b/catalyst_voices_packages/catalyst_cardano/catalyst_cardano_web/lib/src/interop/catalyst_cardano_wallet_proxy.dart @@ -171,7 +171,7 @@ class JSCardanoWalletApiProxy implements CardanoWalletApi { } @override - Future> getUtxos({ + Future> getUtxos({ Balance? amount, Paginate? paginate, }) async { @@ -183,7 +183,7 @@ class JSCardanoWalletApiProxy implements CardanoWalletApi { paginate != null ? JSPaginate.fromDart(paginate) : makeUndefined(), ); - if (utxos == null) return []; + if (utxos == null) return {}; return await utxos.toDart.then( (array) => array.toDart @@ -192,7 +192,7 @@ class JSCardanoWalletApiProxy implements CardanoWalletApi { cbor.decode(hex.decode(item.toDart)), ), ) - .toList(), + .toSet(), ); } catch (ex) { throw _mapApiException(ex) ?? diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/README.md b/catalyst_voices_packages/catalyst_cardano_serialization/README.md index 7354c3ecce..e9c6c10e71 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/README.md +++ b/catalyst_voices_packages/catalyst_cardano_serialization/README.md @@ -52,9 +52,10 @@ import 'package:convert/convert.dart'; /* cSpell:disable */ void main() { const txBuilderConfig = TransactionBuilderConfig( - feeAlgo: LinearFee( - constant: Coin(155381), - coefficient: Coin(44), + feeAlgo: TieredFee( + constant: 155381, + coefficient: 44, + refScriptByteCost: 0, ), maxTxSize: 16384, maxValueSize: 5000, @@ -90,7 +91,7 @@ void main() { 'addr_test1qpu5vlrf4xkxv2qpwngf6cjhtw542ayty80v8dyr49rf5ewvxwdrt70' 'qlcpeeagscasafhffqsxy36t90ldv06wqrk2qum8x5w', ), - amount: const Value(coin: Coin(10162333)), + amount: const Balance(coin: Coin(10162333)), ), ); @@ -98,12 +99,12 @@ void main() { address: ShelleyAddress.fromBech32( 'addr_test1vzpwq95z3xyum8vqndgdd9mdnmafh3djcxnc6jemlgdmswcve6tkw', ), - amount: const Value(coin: Coin(1000000)), + amount: const Balance(coin: Coin(1000000)), ); final txBuilder = TransactionBuilder( config: txBuilderConfig, - inputs: [utxo], + inputs: {utxo}, // fee can be left empty so that it's auto calculated or can be hardcoded // fee: const Coin(1000000), ttl: const SlotBigNum(410021), @@ -194,15 +195,15 @@ Byron era addresses are not supported. | 5 = reward withdrawals | ❌️ | | 6 = protocol parameter update | ❌️ | | 7 = auxiliary_data_hash | ✔️ | -| 8 = validity interval start | ❌️ | -| 9 = mint | ❌️ | -| 11 = script_data_hash | ❌️ | -| 13 = collateral inputs | ❌️ | +| 8 = validity interval start | ✔️ | +| 9 = mint | ✔️ | +| 11 = script_data_hash | ✔️ | +| 13 = collateral inputs | ✔️ | | 14 = required signers | ✔️ | | 15 = network_id | ✔️ | -| 16 = collateral return | ❌️ | -| 17 = total collateral | ❌️ | -| 18 = reference inputs | ❌️ | +| 16 = collateral return | ✔️ | +| 17 = total collateral | ✔️ | +| 18 = reference inputs | ✔️ | ## Reference documentation diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/example/main.dart b/catalyst_voices_packages/catalyst_cardano_serialization/example/main.dart index bb01322031..6183e5010f 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/example/main.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/example/main.dart @@ -7,9 +7,10 @@ import 'package:convert/convert.dart'; /* cSpell:disable */ void main() { const txBuilderConfig = TransactionBuilderConfig( - feeAlgo: LinearFee( - constant: Coin(155381), - coefficient: Coin(44), + feeAlgo: TieredFee( + constant: 155381, + coefficient: 44, + refScriptByteCost: 15, ), maxTxSize: 16384, maxValueSize: 5000, @@ -58,7 +59,7 @@ void main() { final txBuilder = TransactionBuilder( config: txBuilderConfig, - inputs: [utxo], + inputs: {utxo}, // fee can be left empty so that it's auto calculated or can be hardcoded // fee: const Coin(1000000), ttl: const SlotBigNum(410021), diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/builders/transaction_builder.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/builders/transaction_builder.dart index b6470a4676..9d53004f9a 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/builders/transaction_builder.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/builders/transaction_builder.dart @@ -19,7 +19,7 @@ final class TransactionBuilder extends Equatable { /// /// Enough [inputs] must be provided to be greater or equal /// the amount of [outputs] + [fee]. - final List inputs; + final Set inputs; /// The list of transaction outputs which describes which address /// will receive what amount of [Coin]. @@ -38,13 +38,34 @@ final class TransactionBuilder extends Equatable { /// The transaction metadata as a list of key-value pairs (a map). final AuxiliaryData? auxiliaryData; - /// The list of public key hashes of addresses - /// that are required to sign the transaction. + /// Validity interval start as integer. + final SlotBigNum? validityStart; + + /// Mint as a non-zero uint64 multiasset. + final MultiAsset? mint; + + /// The transaction metadata as a list of key-value pairs (a map). + final ScriptData? scriptData; + + /// Collateral inputs as nonempty set. + final Set? collateralInputs; + + /// The list of public key hashes of addresses that are required to sign the + /// transaction. A nonempty set of addr key hashes. final Set? requiredSigners; /// Specifies on which network the code will run. final NetworkId? networkId; + /// Collateral return's transaction output. + final ShelleyMultiAssetTransactionOutput? collateralReturn; + + /// Total collateral as coin (uint64). + final Coin? totalCollateral; + + /// Reference inputs as nonempty set of transaction inputs. + final Set? referenceInputs; + /// The builder that builds the witness set of the transaction. /// /// The caller must know in advance how many witnesses there will be to @@ -60,8 +81,15 @@ final class TransactionBuilder extends Equatable { this.fee, this.ttl, this.auxiliaryData, + this.validityStart, + this.mint, + this.scriptData, + this.collateralInputs, this.requiredSigners, this.networkId, + this.collateralReturn, + this.totalCollateral, + this.referenceInputs, this.witnessBuilder = const TransactionWitnessSetBuilder( vkeys: {}, vkeysCount: 1, @@ -221,7 +249,7 @@ final class TransactionBuilder extends Equatable { Coin minFee() { final txBody = _copyWith(fee: const Coin(Numbers.intMaxValue)).buildBody(); final fullTx = buildFakeTransaction(txBody); - return config.feeAlgo.minNoScriptFee(fullTx); + return config.feeAlgo.minFee(fullTx, {...inputs, ...?referenceInputs}); } @override @@ -232,8 +260,15 @@ final class TransactionBuilder extends Equatable { fee, ttl, auxiliaryData, + validityStart, + mint, + scriptData, + collateralInputs, requiredSigners, networkId, + collateralReturn, + totalCollateral, + referenceInputs, witnessBuilder, ]; @@ -476,8 +511,17 @@ final class TransactionBuilder extends Equatable { auxiliaryDataHash: auxiliaryData != null ? AuxiliaryDataHash.fromAuxiliaryData(auxiliaryData!) : null, + validityStart: validityStart, + mint: mint, + scriptDataHash: scriptData != null + ? ScriptDataHash.fromScriptData(scriptData!) + : null, + collateralInputs: collateralInputs, requiredSigners: requiredSigners, networkId: networkId, + collateralReturn: collateralReturn, + totalCollateral: totalCollateral, + referenceInputs: referenceInputs?.map((utxo) => utxo.input).toSet(), ); } @@ -493,8 +537,15 @@ final class TransactionBuilder extends Equatable { fee: fee ?? this.fee, ttl: ttl, auxiliaryData: auxiliaryData, + validityStart: validityStart, + mint: mint, + scriptData: scriptData, + collateralInputs: collateralInputs, requiredSigners: requiredSigners, networkId: networkId, + collateralReturn: collateralReturn, + totalCollateral: totalCollateral, + referenceInputs: referenceInputs, witnessBuilder: witnessBuilder ?? this.witnessBuilder, ); } @@ -504,7 +555,7 @@ final class TransactionBuilder extends Equatable { /// protocol parameters and other constants. final class TransactionBuilderConfig extends Equatable { /// The protocol parameter which describes the transaction fee algorithm. - final LinearFee feeAlgo; + final TieredFee feeAlgo; /// The protocol parameter which limits the maximum transaction size in bytes. final int maxTxSize; diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/exceptions.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/exceptions.dart index fb27c13362..f809d27d54 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/exceptions.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/exceptions.dart @@ -190,3 +190,21 @@ final class InsufficientAdaForAssetsException extends Equatable @override List get props => []; } + +/// Exception thrown when the total size of reference scripts exceeds the limit. +final class ReferenceScriptSizeLimitExceededException extends Equatable + implements Exception { + /// The maximum size of reference scripts allowed per transaction. + final int maxRefScriptSize; + + /// The default constructor for [ReferenceScriptSizeLimitExceededException]. + const ReferenceScriptSizeLimitExceededException(this.maxRefScriptSize); + + @override + String toString() => + 'Total size of reference scripts exceeds the limit of $maxRefScriptSize ' + 'bytes'; + + @override + List get props => [maxRefScriptSize]; +} diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/fees.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/fees.dart index f0aaad83a1..954c1d6927 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/fees.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/fees.dart @@ -1,35 +1,172 @@ +import 'package:catalyst_cardano_serialization/src/exceptions.dart'; import 'package:catalyst_cardano_serialization/src/transaction.dart'; +import 'package:catalyst_cardano_serialization/src/transaction_output.dart'; import 'package:catalyst_cardano_serialization/src/types.dart'; import 'package:cbor/cbor.dart'; import 'package:equatable/equatable.dart'; -/// Calculates fees for the transaction on Cardano blockchain. +/// A final class that implements a flexible fee algorithm. It calculates +/// fees for transactions using both linear and tiered logic based on the +/// size of reference scripts. /// -/// The fee is calculated using the following formula: -/// - `fee = constant + tx.bytes.len * coefficient` -final class LinearFee extends Equatable { - /// The constant amount of [Coin] that is charged per transaction. - final Coin constant; +/// If the [refScriptByteCost] parameter is `0` or if there are no reference +/// scripts in the `inputs`, then the [TieredFee] class behaves like the +/// pre-Conway linear fee model. +final class TieredFee extends Equatable { + /// The constant amount of lovelace charged per transaction. + /// + /// This value is derived from the protocol parameter `minFeeA`. + final int constant; + + /// The amount charged per transaction byte. + /// + /// This value is derived from the protocol parameter `minFeeB`. + final int coefficient; + + /// The multiplier for the tiered fee algorithm. + /// This factor increases the fee per byte at each size increment. + /// + /// For the Conway era, this value on mainnet is set to 1.2. + final double multiplier; + + /// The size increment for the tiered fee algorithm. + /// The fee per byte increases after this size. + /// + /// For the Conway era, this value on mainnet is set to 25,600 bytes. + final int sizeIncrement; - /// The amount of [Coin] per transaction byte that is charged per transaction. - final Coin coefficient; + /// The cost per byte for reference scripts. + /// This value is derived from the protocol parameter + /// `minFeeRefScriptCostPerByte`. + final int refScriptByteCost; - /// The default constructor for [LinearFee]. + /// The maximum size of reference scripts allowed per transaction. /// - /// The parameters are Cardano protocol parameters. - const LinearFee({ + /// This value is currently hardcoded, but will be turned into an actual + /// protocol parameter in the next era after Conway. + /// + /// For the Conway era, this value on mainnet is set to 204,800 bytes + /// (200 KiB). + final int maxRefScriptSize; + + /// The default constructor for [TieredFee], which applies both linear and + /// tiered pricing based on transaction size. + /// + /// The parameters represent Cardano protocol values. + /// > Note: The [multiplier], [sizeIncrement] and [maxRefScriptSize] are + /// currently hardcoded for mainnet but may become protocol parameters in + /// the future. + const TieredFee({ required this.constant, required this.coefficient, + this.multiplier = 1.2, + this.sizeIncrement = 25600, + required this.refScriptByteCost, + this.maxRefScriptSize = 204800, }); - /// Calculates the fee for the transaction denominated in lovelaces. + /// Calculates the minimum fee for the transaction, adding any reference + /// script-related costs if applicable. + Coin minFee(Transaction tx, Set inputs) { + final refScriptsBytes = _calculateReferenceScriptSize(inputs); + + if (refScriptsBytes > maxRefScriptSize) { + throw ReferenceScriptSizeLimitExceededException(maxRefScriptSize); + } + + final minTxFee = tieredFee( + cbor.encode(tx.toCbor()).length, + refScriptsBytes, + ); + + return Coin(minTxFee); + } + + /// Calculates the linear fee for a transaction based on its size in bytes. + /// + /// The linear fee formula is: + /// - `fee = constant + (tx.bytes.len * coefficient)` + /// + /// > This formula does not account for smart contract scripts. + int linearFee(int bytesCount) => bytesCount * coefficient + constant; + + /// Calculates the fee for a transaction using the tiered pricing model. + /// This includes both the linear fee and, if applicable, a reference script + /// fee. + int tieredFee(int txBytes, int refScriptsBytes) { + final txFee = linearFee(txBytes); + final scriptFee = refScriptByteCost > 0 && refScriptsBytes > 0 + ? refScriptFee( + multiplier, + sizeIncrement, + refScriptByteCost, + refScriptsBytes, + ) + : 0; + return txFee + scriptFee; + } + + /// Calculates the fee for the reference scripts. /// - /// The formula doesn't take into account smart contract scripts. - Coin minNoScriptFee(Transaction tx) { - final bytesCount = cbor.encode(tx.toCbor()).length; - return Coin(bytesCount) * coefficient + constant; + /// The reference script fee is based on a tiered pricing model: + /// - The fee increases for each additional `25KiB` chunk of reference + /// script size. + /// - The first `25KiB` is charged at a base cost per byte, and subsequent + /// chunks are charged at progressively higher rates, multiplied by `1.2`. + int refScriptFee( + double multiplier, + int sizeIncrement, + int refScriptCostPerByte, + int resScriptSize, + ) { + int calcRefScriptFee(double acc, double curTierPrice, int n) => + n <= sizeIncrement + ? (acc + n * curTierPrice).floor() + : calcRefScriptFee( + acc + curTierPrice * sizeIncrement, + curTierPrice * multiplier, + n - sizeIncrement, + ); + return calcRefScriptFee( + 0, + refScriptCostPerByte.toDouble(), + resScriptSize, + ); + } + + /// Calculates the total size of reference scripts used in a transaction. + /// + /// This includes the sizes of reference scripts from both inputs and + /// reference inputs. Outputs without reference scripts do not contribute + /// to the total size. + /// + /// Duplicate reference scripts are counted each time they appear in the + /// transaction, i.e., when the same script is used on different inputs. + /// + /// However, any input that appears in both regular inputs and + /// reference inputs of a transaction is only used once in this computation. + int _calculateReferenceScriptSize(Set inputs) { + final totalSize = inputs.fold( + 0, + (prevSize, input) => + prevSize + + switch (input.output) { + final TransactionOutput output => + output.scriptRef != null ? output.scriptRef!.length : 0, + _ => 0, + }, + ); + + return totalSize; } @override - List get props => [constant, coefficient]; + List get props => [ + constant, + coefficient, + multiplier, + sizeIncrement, + refScriptByteCost, + maxRefScriptSize, + ]; } diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/hashes.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/hashes.dart index dd46935752..6fa16006d0 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/hashes.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/hashes.dart @@ -94,7 +94,7 @@ final class TransactionInputsHash extends BaseHash { /// Constructs the [TransactionInputsHash] from a [TransactionBody]. TransactionInputsHash.fromTransactionInputs( - List utxos, + Set utxos, ) : super.fromBytes( bytes: Hash.blake2b( Uint8List.fromList( diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/scripts.dart b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/scripts.dart index 1cda0ef908..6e5a6c71dc 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/scripts.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/lib/src/scripts.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:catalyst_cardano_serialization/src/hashes.dart'; import 'package:catalyst_cardano_serialization/src/types.dart'; import 'package:cbor/cbor.dart'; +import 'package:convert/convert.dart'; import 'package:equatable/equatable.dart'; import 'package:pinenacl/digests.dart'; @@ -89,24 +90,25 @@ sealed class Script extends Equatable implements CborEncodable { /// Blake2b hash of the resulting bytes. Uint8List get hash { final cborValue = toCbor(); - final bytesToHash = cborValue is CborBytes - ? _handleDoubleEncodedCbor(cborValue.bytes) - : cborValue; + final cborBytes = cbor.encode(cborValue); - final cborBytes = cbor.encode(bytesToHash); final bytes = Uint8List.fromList([tag, ...cborBytes]); return Hash.blake2b(bytes, digestSize: scriptHashSize); } - CborValue _handleDoubleEncodedCbor(List bytes) { - final decoded = cbor.decode(bytes); - if (decoded is CborBytes) { + static CborValue _handleDoubleEncodedCbor(CborValue cborValue) { + if (cborValue is CborBytes) { try { - return cbor.decode(decoded.bytes); + return cbor.decode(cborValue.bytes); } catch (_) {} } - return decoded; + return cborValue; } + + /// The length of the script in bytes. + /// + /// This is an abstract getter that must be implemented by the child classes. + int get length; } /// Abstract base class for native scripts, extending [Script]. @@ -222,6 +224,10 @@ sealed class NativeScript extends Script { _invalidCborError(value); } } + + /// Returns the length of the [NativeScript]'s in bytes. + @override + int get length => cbor.encode(toCbor()).length; } /// Class representing a public key based native script. @@ -405,7 +411,7 @@ class InvalidAfter extends NativeScript { /// Abstract base class for Plutus scripts, extending [Script]. sealed class PlutusScript extends Script { /// [PlutusScript] represented as encoded CBOR bytes. - final Uint8List bytes; + final List bytes; // TODO(ilap): Check whether the Plutus script bytes are valid CBOR CborByte-s // and throw an error if does not. @@ -418,50 +424,76 @@ sealed class PlutusScript extends Script { CborValue toCbor() => CborBytes(bytes); /// Validates if the CBOR value is a valid Plutus script. - static void _plutusScriptValidity(CborValue value) { - if (value is! CborBytes) { - throw ArgumentError.value(value, 'value', 'Invalid Plutus script cbor'); - } + static CborValue _plutusScriptValidity(CborValue value) { + return value is CborBytes + ? Script._handleDoubleEncodedCbor(value) + : throw ArgumentError.value( + value, + 'value', + 'Invalid Plutus script cbor', + ); } /// Equatable props for value comparison of all Plutus scripts. @override List get props => [bytes]; + + /// Returns the length of the [PlutusScript]'s in bytes. + @override + int get length => cbor.encode(CborBytes(bytes)).length; } /// Class representing a Plutus V1 script. class PlutusV1Script extends PlutusScript { /// [PlutusV1Script] constructor. - const PlutusV1Script(super.bytes); + const PlutusV1Script._(super.bytes); /// Factory constructor to create an [PlutusV1Script] from a CBOR list. factory PlutusV1Script.fromCbor(CborValue value) { - PlutusScript._plutusScriptValidity(value); - return PlutusV1Script(Uint8List.fromList((value as CborBytes).bytes)); + final validCbor = PlutusScript._plutusScriptValidity(value); + return PlutusV1Script._((validCbor as CborBytes).bytes); + } + + /// Factory constructor to create an [PlutusV2Script] from a CBOR hex string. + factory PlutusV1Script.fromHex(String cborHex) { + final cborValue = cbor.decode(hex.decode(cborHex)); + return PlutusV1Script.fromCbor(cborValue); } } /// Class representing a Plutus V2 script. class PlutusV2Script extends PlutusScript { /// [PlutusV2Script] constructor. - const PlutusV2Script(super.bytes); + const PlutusV2Script._(super.bytes); /// Factory constructor to create an [PlutusV2Script] from a CBOR list. - factory PlutusV2Script.fromCbor(CborValue value) { - PlutusScript._plutusScriptValidity(value); - return PlutusV2Script(Uint8List.fromList((value as CborBytes).bytes)); + factory PlutusV2Script.fromCbor(CborValue cborValue) { + final validCbor = PlutusScript._plutusScriptValidity(cborValue); + return PlutusV2Script._((validCbor as CborBytes).bytes); + } + + /// Factory constructor to create an [PlutusV2Script] from a CBOR hex string. + factory PlutusV2Script.fromHex(String cborHex) { + final cborValue = cbor.decode(hex.decode(cborHex)); + return PlutusV2Script.fromCbor(cborValue); } } /// Class representing a Plutus V3 script. class PlutusV3Script extends PlutusScript { /// [PlutusV3Script] constructor. - const PlutusV3Script(super.bytes); + const PlutusV3Script._(super.bytes); /// Factory constructor to create an [PlutusV3Script] from a CBOR list. - factory PlutusV3Script.fromCbor(CborValue value) { - PlutusScript._plutusScriptValidity(value); - return PlutusV3Script(Uint8List.fromList((value as CborBytes).bytes)); + factory PlutusV3Script.fromCbor(CborValue cborValue) { + final validCbor = PlutusScript._plutusScriptValidity(cborValue); + return PlutusV3Script._((validCbor as CborBytes).bytes); + } + + /// Factory constructor to create an [PlutusV2Script] from a CBOR hex string. + factory PlutusV3Script.fromHex(String cborHex) { + final cborValue = cbor.decode(hex.decode(cborHex)); + return PlutusV3Script.fromCbor(cborValue); } } @@ -512,4 +544,8 @@ class ScriptRef extends Script { /// Equatable props for value comparison. @override List get props => [script]; + + /// Returns the length of the [ScriptRef]'s script in bytes. + @override + int get length => script.length; } diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/fees_test.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/fees_test.dart index 4ed5f2898a..09ca244ec3 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/test/fees_test.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/fees_test.dart @@ -5,35 +5,227 @@ import 'package:test/test.dart'; import 'test_utils/test_data.dart'; void main() { - group(LinearFee, () { - test('minFeeNoScript with current protocol params', () { - const linearFee = LinearFee( - constant: Coin(155381), - coefficient: Coin(44), + final txInputs = { + testUtxo(index: 0, scriptRef: refInputScripts[0]), + testUtxo(index: 1, scriptRef: refInputScripts[1]), + testUtxo(index: 2, scriptRef: refInputScripts[2]), + }; + + final refInputs = { + testUtxo(index: 3, scriptRef: refInputScripts[0]), + testUtxo(index: 4, scriptRef: refInputScripts[1]), + testUtxo(index: 5, scriptRef: refInputScripts[2]), + }; + + group(TieredFee, () { + test('minFee with current protocol params', () { + const tieredFee = TieredFee( + constant: 155381, + coefficient: 44, + refScriptByteCost: 15, ); final tx = fullSignedTestTransaction(); - expect(linearFee.minNoScriptFee(tx), equals(177777)); + expect(tieredFee.minFee(tx, {}), equals(const Coin(177777))); }); - test('minFeeNoScript with constant fee only', () { - const linearFee = LinearFee( - constant: Coin(155381), - coefficient: Coin(0), + test('minFee with constant fee only', () { + const tieredFee = TieredFee( + constant: 155381, + coefficient: 0, + refScriptByteCost: 15, ); final tx = fullSignedTestTransaction(); - expect(linearFee.minNoScriptFee(tx), equals(linearFee.constant)); + expect(tieredFee.minFee(tx, {}), equals(Coin(tieredFee.constant))); }); - test('minFeeNoScript with coefficient fee only', () { - const linearFee = LinearFee( - constant: Coin(0), - coefficient: Coin(44), + test('minFee with coefficient fee only', () { + const tieredFee = TieredFee( + constant: 0, + coefficient: 44, + refScriptByteCost: 15, ); final tx = fullSignedTestTransaction(); - expect(linearFee.minNoScriptFee(tx), equals(22396)); + expect(tieredFee.minFee(tx, {}), equals(const Coin(22396))); + }); + + test('refScriptFee is a linear function when multiplier is 1', () { + const scriptByteFee = 15; + const size = 500; + + const tieredFee = TieredFee( + constant: 155381, + coefficient: 44, + multiplier: 1, + refScriptByteCost: 15, + ); + + const expectedFee = size * scriptByteFee; + final actualFee = tieredFee.refScriptFee( + 1, + tieredFee.sizeIncrement, + scriptByteFee, + size, + ); + + expect(actualFee, equals(expectedFee)); + }); + + test('tierRefScriptFee with tiered pricing', () { + const multiplier = 1.5; + const sizeIncrement = 25600; + const baseFee = 15; + + const tieredFee = TieredFee( + constant: 0, + coefficient: 44, + refScriptByteCost: 15, + ); + + const sizes = [ + 0, + sizeIncrement, + 2 * sizeIncrement, + 3 * sizeIncrement, + 4 * sizeIncrement, + 5 * sizeIncrement, + 6 * sizeIncrement, + 7 * sizeIncrement, + 8 * sizeIncrement, + ]; + + const expectedFees = [ + 0, + 384000, + 960000, + 1824000, + 3120000, + 5064000, + 7980000, + 12354000, + 18915000, + ]; + final actualFees = sizes + .map( + (size) => tieredFee.refScriptFee( + multiplier, + sizeIncrement, + baseFee, + size, + ), + ) + .toList(); + + expect(actualFees, equals(expectedFees)); + }); + + test('refScriptFee for a large reference script size and multiplier', () { + const tieredFee = TieredFee( + constant: 155381, + coefficient: 44, + multiplier: 1.5, + refScriptByteCost: 15, + ); + + const size = 100000; + const expectedFee = 2998500; + final actualFee = tieredFee.refScriptFee( + tieredFee.multiplier, + tieredFee.sizeIncrement, + tieredFee.refScriptByteCost, + size, + ); + + expect(actualFee, equals(expectedFee)); + }); + + test('tieredFee and refScriptFee calculations', () { + const byteCost = 15; + const tieredFee = TieredFee( + constant: 155381, + coefficient: 44, + refScriptByteCost: byteCost, + ); + + const txBytes = 10000; + const refScriptsBytes = 5000; + + final linearFee = tieredFee.linearFee(txBytes); + final scriptFee = + tieredFee.refScriptFee(1.5, 25600, byteCost, refScriptsBytes); + final expectedFee = Coin(linearFee + scriptFee); + final actualFee = tieredFee.tieredFee(txBytes, refScriptsBytes); + + expect(actualFee, equals(expectedFee)); }); }); + + test('minFee for a large transaction with multiple reference scripts', () { + const tieredFee = TieredFee( + constant: 155381, + coefficient: 44, + refScriptByteCost: 15, + ); + + final inputs = txInputs.map((utxo) => utxo.input).toSet(); + + final tx = fullSignedTestTransaction(inputs: inputs); + final sum = + refInputScriptsSizes.reduce((value, element) => value + element); + + final refScriptsFee = tieredFee.refScriptFee( + tieredFee.multiplier, + tieredFee.sizeIncrement, + tieredFee.refScriptByteCost, + sum, + ); + + const expectedFee = 180945; + + final minNoScriptFee = tieredFee.minFee(tx, {}); + + // With no reference scripts. + expect(minNoScriptFee, equals(const Coin(expectedFee))); + + expect( + tieredFee.minFee(tx, refInputs), + equals(minNoScriptFee + Coin(refScriptsFee)), + ); + }); + + test('minFee for a large transaction with duplicate reference scripts', () { + const tieredFee = TieredFee( + constant: 155381, + coefficient: 44, + refScriptByteCost: 15, + ); + + final inputs = + {...txInputs, ...refInputs}.map((utxo) => utxo.input).toSet(); + + final tx = fullSignedTestTransaction(inputs: inputs); + final sum = + refInputScriptsSizes.reduce((value, element) => value + element); + + final refScriptsFee = tieredFee.refScriptFee( + tieredFee.multiplier, + tieredFee.sizeIncrement, + tieredFee.refScriptByteCost, + 2 * sum, + ); + + const expectedFee = 185697; + + final minNoScriptFee = tieredFee.minFee(tx, {}); + + // With no reference scripts. + expect(minNoScriptFee, equals(const Coin(expectedFee))); + + expect( + tieredFee.minFee(tx, {...txInputs, ...refInputs}), + equals(minNoScriptFee + Coin(refScriptsFee)), + ); + }); } diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/hashes_test.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/hashes_test.dart index 8136b31c12..537207d5a6 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/test/hashes_test.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/hashes_test.dart @@ -66,7 +66,7 @@ void main() { }); test('from transaction inputs', () { - final hash = TransactionInputsHash.fromTransactionInputs([testUtxo()]); + final hash = TransactionInputsHash.fromTransactionInputs({testUtxo()}); expect( hash, equals( diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/scripts_test.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/scripts_test.dart index ca5a4ccf53..c6abc2e59d 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/test/scripts_test.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/scripts_test.dart @@ -1,5 +1,5 @@ -import 'dart:typed_data'; import 'package:catalyst_cardano_serialization/catalyst_cardano_serialization.dart'; +import 'package:cbor/cbor.dart'; import 'package:convert/convert.dart'; import 'package:test/test.dart'; @@ -189,6 +189,17 @@ void main() { final json = script['json']! as Map; final simpleScript = NativeScript.fromJSON(json); + final cborValue = simpleScript.toCbor(); + final cborLen = cbor.encode(cborValue).length; + final fromCbor = NativeScript.fromCbor(cborValue); + + // Length check + expect(cborLen, equals(simpleScript.length)); + + // from/toCbor check + expect(simpleScript, equals(fromCbor)); + + // Golden hash test. expect( simpleScript.hash, equals(hex.decode(script['hash']! as String)), @@ -200,10 +211,21 @@ void main() { group(PlutusScript, () { test('hashes (V2 only)', () { for (final script in plutusV2ScriptsJSON) { - final scriptCborBytes = - Uint8List.fromList(hex.decode(script['compiledCode']!)); - final plutusScript = PlutusV2Script(scriptCborBytes); + final compiledCode = script['compiledCode']!; + final scriptLength = compiledCode.length ~/ 2; + + final decodedCode = cbor.decode(hex.decode(compiledCode)); + + final plutusScript = PlutusV2Script.fromHex(compiledCode); + final cborValue = plutusScript.toCbor(); + // Length check + expect(scriptLength, equals(plutusScript.length)); + + // from/toCbor check + expect(cborValue, equals(decodedCode)); + + // Golden hash test expect(plutusScript.hash, equals(hex.decode(script['hash']!))); } }); @@ -216,15 +238,21 @@ void main() { final hash = json['hash']! as String; final type = script['type'] as String; final cborHex = script['cborHex'] as String; - final cborBytes = Uint8List.fromList(hex.decode(cborHex)); + + // The cborHex has a `45` prefix. + final scriptLength = cborHex.length ~/ 2 - 1; final plutusScript = switch (type) { - 'PlutusScriptV1' => PlutusV1Script(cborBytes), - 'PlutusScriptV2' => PlutusV2Script(cborBytes), - 'PlutusScriptV3' => PlutusV3Script(cborBytes), + 'PlutusScriptV1' => PlutusV1Script.fromHex(cborHex), + 'PlutusScriptV2' => PlutusV2Script.fromHex(cborHex), + 'PlutusScriptV3' => PlutusV3Script.fromHex(cborHex), _ => throw Exception('Unknown script type: $type'), }; + // Length check + expect(scriptLength, equals(plutusScript.length)); + + // Golden hash test expect(plutusScript.hash, equals(hex.decode(hash))); } }); diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/test_utils/test_data.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/test_utils/test_data.dart index 829f338872..45c7d330e7 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/test/test_utils/test_data.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/test_utils/test_data.dart @@ -1,5 +1,6 @@ import 'package:catalyst_cardano_serialization/src/address.dart'; import 'package:catalyst_cardano_serialization/src/hashes.dart'; +import 'package:catalyst_cardano_serialization/src/scripts.dart'; import 'package:catalyst_cardano_serialization/src/signature.dart'; import 'package:catalyst_cardano_serialization/src/transaction.dart'; import 'package:catalyst_cardano_serialization/src/transaction_output.dart'; @@ -31,26 +32,27 @@ final testTransactionHash = TransactionHash.fromHex( '4c1fbc5433ec764164945d736a09dc087d59ff30e64d26d462ff8570cd4be9a7', ); -TransactionUnspentOutput testUtxo() { +TransactionUnspentOutput testUtxo({int? index, ScriptRef? scriptRef}) { return TransactionUnspentOutput( input: TransactionInput( transactionId: testTransactionHash, - index: 0, + index: index ?? 0, ), - output: PreBabbageTransactionOutput( + output: TransactionOutput( address: ShelleyAddress.fromBech32( 'addr_test1qpu5vlrf4xkxv2qpwngf6cjhtw542ayty80v8dyr49rf5' 'ewvxwdrt70qlcpeeagscasafhffqsxy36t90ldv06wqrk2qum8x5w', ), amount: const Balance(coin: Coin(100000000)), + scriptRef: scriptRef, ), ); } -Transaction minimalUnsignedTestTransaction() { +Transaction minimalUnsignedTestTransaction({Set? inputs}) { return Transaction( body: TransactionBody( - inputs: {testUtxo().input}, + inputs: inputs ?? {testUtxo().input}, outputs: [ PreBabbageTransactionOutput( address: testnetAddr, @@ -69,10 +71,10 @@ Transaction minimalUnsignedTestTransaction() { } /// Returns a minimal transaction with optional fields skipped. -Transaction minimalSignedTestTransaction() { +Transaction minimalSignedTestTransaction({Set? inputs}) { return Transaction( body: TransactionBody( - inputs: {testUtxo().input}, + inputs: inputs ?? {testUtxo().input}, outputs: [ PreBabbageTransactionOutput( address: testnetAddr, @@ -108,12 +110,12 @@ Transaction minimalSignedTestTransaction() { ); } -Transaction fullUnsignedTestTransaction() { +Transaction fullUnsignedTestTransaction({Set? inputs}) { final auxiliaryData = testAuxiliaryData(); return Transaction( body: TransactionBody( - inputs: {testUtxo().input}, + inputs: inputs ?? {testUtxo().input}, outputs: [ PreBabbageTransactionOutput( address: testnetAddr, @@ -146,12 +148,12 @@ Transaction fullUnsignedTestTransaction() { } /// Returns a full transaction with all possible optional fields. -Transaction fullSignedTestTransaction() { +Transaction fullSignedTestTransaction({Set? inputs}) { final auxiliaryData = testAuxiliaryData(); return Transaction( body: TransactionBody( - inputs: {testUtxo().input}, + inputs: inputs ?? {testUtxo().input}, outputs: [ PreBabbageTransactionOutput( address: testnetAddr, @@ -274,3 +276,346 @@ A2679B6E682D2A26945ED0B2 .replaceAll('\n', ''); /* cSpell:enable */ + +/// Reference scripts that exist on the Preview network, with sizes verified. +const refInputScriptsSizes = [2470, 6296, 1579]; + +final refInputScripts = [ + ScriptRef( + PlutusV2Script.fromHex( + '5909a30100003323232323232323232323232322322323232323222533300f32' + '323253330123008301437540022646464646464a666030601e60346ea80044c8' + 'c8c94ccc07c00454cc07005c584c94ccc080c08c0084c8c94ccc078cdc3a4008' + '60406ea80044c8c8c8c8c8c94ccc090cccc004c8cc004004040894ccc0a80045' + '2f5bded8c0264646464a66605466e45221000021533302a3371e910100002100' + '3100513302f337606ea4008dd3000998030030019bab302c003375c605400460' + '5c00460580026eb8c008c09cdd50029bae30083027375400aa66604800629001' + '0a4002264a66604a666600400203e666e31200048008dd7180498141baa00648' + '00854cc8cc098cdc399918008009129998160008a40002600666004004605e00' + '2646600200200644a666058002297ae013302d302a302e00133002002302f001' + '480104c8c8c8c94ccc0a8cdc39bad3014302d3754016600a6eb4c050c0b4dd50' + '110a99981519b894800000454ccc0a8cdc380099b80002533302a00914800852' + '00113371e6eb8c020c0b4dd50111bae3008302d375401629405280a503002375' + 'c600660586ea8028c004dd7180118159baa02023253330293370e66e38009200' + '04818054ccc0a4c07c004520001613232333001001480012000222533302d337' + '0e0020082004266600600664a66605ca66605c66e25206000113371200290390' + 'a5013370066e0800d201433700002902f8a99817a4811f657870656374206279' + '7465203e3d2034382026262062797465203c3d20353700163371c00c00260100' + '026e34008dc6800918169817181718170009b804800854cc0992413565787065' + '6374206c6973742e6c656e6774682876616c75652e706f6c6963696573286f75' + '747075745f76616c75652929203d3d203200161533026491866578706563740a' + '2020202020206861735f6f6e6c795f746f6b656e280a20202020202020206f75' + '747075745f76616c75652c0a2020202020202020617574685f746f6b656e2c0a' + '20202020202020206279746561727261792e74616b65286e65775f7374617465' + '2e6164617461672c2031292c0a2020202020202020312c0a2020202020202900' + '1637566018604e6ea802454cc0952401a66578706563740a202020202020636f' + '72726563745f6e66745f616d6f756e74280a20202020202020206d696e74696e' + '673a206d696e74696e672c0a20202020202020206d696e743a206d696e742c0a' + '202020202020202073796d626f6c3a206e65775f73746174652e6d696e74696e' + '675f706f6c6963792c0a2020202020202020746f6b656e5f6e616d653a206e65' + '775f73746174652e6164617461672c0a202020202020290016222232533302c0' + '01153302902716132533302d3030002132533302a3371e6eb8c0b00080144cdc' + '38008020a50375a60580022a660540502c605c00264a666050603c60546ea800' + '452f5bded8c026eacc0b8c0acdd500099198008008029129998168008a6103d8' + '7a8000132323232533302d337220120042a66605a66e3c0240084cdd2a400066' + '0646e980052f5c02980103d87a80001330060060033756605e0066eb8c0b4008' + 'c0c4008c0bc0048c0a4c0a8c0a8c0a8c0a8c0a8004cdd7980498121baa0024c1' + '03d87980003301500101d53333330270011001153302001c16153302001c1615' + '3302001c16153302001c163024302137540022a6603e92013065787065637420' + '496e6c696e65446174756d286e65775f737461746529203d206f776e5f6f7574' + '7075742e646174756d001630013020375400446046604860480022a6603a0302' + 'c6042002646600200200e44a666040002297ae013232533301e3375e60106042' + '6ea80080144cc08c008cc0100100044cc010010004c090008c088004c07cc070' + 'dd51800980e1baa301f301c37540044603e60400022a66032921346578706563' + '7420536f6d65286f776e5f696e70757429203d2066696e645f696e7075742869' + '6e707574732c206f776e5f7265662900163322323300100100322533301f0011' + '4c0103d87a800013232533301d3375e600e60406ea80080144cdd2a400066044' + '00497ae0133004004001302300230210013758603a00a603a60346ea80188c07' + '4004dd5980d980e180e0011bac301a001301a301a001301537540042a6602692' + '011f657870656374205370656e64286f776e5f72656629203d20707572706f73' + '650016301730180023016001301237540022930a998082491856616c69646174' + '6f722072657475726e65642066616c736500136563300100400a22533300d300' + '4300f3754004264a6660240022a6601e0042c26464a6660280022a660220082c' + '26464a66602c0022a6602600c2c26464a6660300022a6602a0102c26464a6660' + '340022a6602e0142c26464a6660380022a660320182c264a66603a6040004264' + '9319299980d18088008a99980f180e9baa00a149854cc06c0385854ccc068c04' + '000454ccc078c074dd50050a4c2a6603601c2c2a6603601c2c60366ea802454c' + 'c06803458c94cccccc08400454cc0680345854cc0680345854cc0680345854cc' + '068034584dd7000980f000980f00119299999980f8008a9980c0058b0a9980c0' + '058b0a9980c0058b0a9980c0058b09bae001301c001301c00232533333301d00' + '11533016009161533016009161533016009161533016009161375c0026034002' + '603400464a6666660360022a6602800e2c2a6602800e2c2a6602800e2c2a6602' + '800e2c26eb8004c060004c060008c94cccccc064004400454cc0480145854cc0' + '480145854cc0480145854cc04801458c058004c058008c94cccccc05c00454cc' + '04000c5854cc04000c5854cc04000c584dd68008a998080018b180a00098081b' + 'aa002153300e00116370e90011b87480014cccccc040004400454cc024018585' + '4cc0240185854cc0240185854cc02401858dd7000a4981657870656374205b6f' + '776e5f6f75747075745d203d0a2020202020206c6973742e66696c746572280a' + '20202020202020206f7574707574732c0a2020202020202020666e286f757470' + '75743a204f757470757429207b206f75747075742e61646472657373203d3d20' + '696e7075745f61646472657373207d2c0a202020202020290049012365787065' + '6374206e65775f73746174653a205374617465203d206e65775f737461746500' + '4901106f6c645f73746174653a20537461746500490159657870656374205b50' + '61697228746e2c207175616e74697479295d203d0a2020202076616c0a202020' + '2020207c3e20746f6b656e7328706f6c6963795f6964290a2020202020207c3e' + '20646963742e746f5f70616972732829005734ae7155ceaab9e5573eae815d0a' + 'ba257489811e581cbd61dd11567ca6bb8e341e2cd5957cc5072412525e43f145' + 'b6cd15b80001', + ), + ), + ScriptRef( + PlutusV2Script.fromHex( + '59189501000033232323232323232323232323232323222323232322533300f3' + '23232533301230073014375400226464646464646464646464a6660420022a66' + '03c02c2c264a666044604a00426464a66466042600260466ea80084c8c8c8c8c' + '8c94ccc0ac00454cc0a0088584c94ccc0b0c0bc0084c94ccc0a4c024c0acdd50' + '00899191919191929991981819919119299981a299981a1802800899b8900148' + '08052809919299981b18031801240002a66606c600a002264646660020029452' + '000222533303a3370e00200820042666006006a66607464a6660766014002294' + '454ccc0eccdc3800a417c02294454ccc0eccdc3800a40b429444cdc3800a40b8' + '600c00220042940cdc0000a40046e340105280a5030013370000490009b8e002' + '14a06e3400494ccc0c8c0080045288a99981919b89481800044cdc4800a40e42' + '94094ccc0c4cdc4a418402002266e2400520f40114a06eb8c048c0ccdd500309' + '9192999819192999819a9998199919299981a9814981b9baa001132325333037' + '32325333039302e00114a22a666072605a002264a66607464a66607e607c0022' + 'a666076605e607a002294454ccc0ecc0c0c0f40045280b0b1baa3020303d3754' + '6042607a6ea801c4cdc4800802899b88001005375a607e60786ea8008528181d' + '1baa001301e303a3754603c60746ea801040045281919299981c18168008a501' + '5333038302c001132533303932533303e303d0011533303a302e303c00114a22' + 'a666074605e607800229405858dd5180f981e1baa301f303c375400c266e2401' + '00044cdc40020009bad303e303b37540042944c0e4dd5000980e981c9baa301c' + '303937540066eb4c0ecc0e0dd50008a9981b2481396578706563742046696e69' + '7465286c6f7765725f626f756e6429203d2072616e67652e6c6f7765725f626f' + '756e642e626f756e645f747970650016301b303737546036606e6ea8070c01cc' + 'c0e0c01ccc0e0cdd2a4004660706006606c6ea80ad2f5c066070666066945301' + '03d87a80004c0103d87980004bd701981c26010ad8799fd87b80d87a80ff004b' + 'd700a51153303449013574696d655f656c61707365642872616e67652c207061' + '72616d732e646561637469766174696f6e5f74696d6529203f2046616c736500' + '14a0294454ccc0cd4ccc0ccc94ccc0d0c0a0c0d8dd519299981c8008a60103d8' + '7a8000130093303a333035300630373754607600298103d87a80004c0103d879' + '80004bd70191980080081011299981d0008a5eb804c8c94ccc0e0c0b0c0e8dd5' + '00089980200200109981e99981c1804981d1baa303e303b3754002980103d87a' + '80004c0103d8798000330040040025333037300833300a3756603a60746ea8c0' + 'f40080100145300107d8799fd87a80ff0014c103d87a8000303d00114a02944d' + 'd7181c981d181d181d181d181d181d181b1baa02b14a22a6606892013b6f7574' + '7075745f6861735f746f6b656e286f7574707574732c20706172616d732e6164' + '6168616e646c652c2061646174616729203f2046616c73650014a0294454ccc0' + 'ccc94ccc0e000454cc0d40c8584c94ccc0e4c0f00084c94ccc0d8c058c0e0dd5' + '000899191919299981d299981d199911299981e9818800899b89003002153233' + '303e301e002133712600290020018a99981f19b87480180084cdc49800a40100' + '062a66607c66e1d2008002133712600290080018a99981f19b87480280084cdc' + '49800a40400062a66607c66e1d200c00213371260029020001899b894807800c' + 'dc18019bad3009303d375406466601a6eacc080c0f4dd500324500488100371a' + '010294454cc0ed2401516861735f76616c69645f6465706f7369742870617261' + '6d732e6465706f7369745f626173652c206465706f7369742c20627974656172' + '7261792e6c656e677468286164617461672929203f2046616c73650014a02a66' + '607464a666076605e607a6ea80044cdc499b80375a6082607c6ea8004dd69820' + '98211821182118211821181f1baa033002153303c49139657870656374204669' + '6e6974652875707065725f626f756e6429203d2072616e67652e75707065725f' + '626f756e642e626f756e645f7479706500163021303d37546040607a6ea80885' + '288a9981da49336861735f76616c69645f646561646c696e6528706172616d73' + '2c2072616e67652c20646561646c696e6529203f2046616c73650014a02940dd' + '6980f981e1baa0025333038302d303a3754002264a66607a0022a660740702c2' + '6464a66607e0022a660780742c264a66608060860042930a9981e81d8b192999' + '9998220008a9981e81d8b0a9981e81d8b0a9981e81d8b09bad001153303d03b1' + '630410013041002325333333042001153303b03916153303b03916153303b039' + '16153303b039161375c002607e00260766ea800454cc0e40dc594cccccc0fc00' + '4400454cc0e00d85854cc0e00d85854cc0e00d85854cc0e00d858c0f0c0e4dd5' + '0008a9981ba4813465787065637420496e6c696e65446174756d2874696d6564' + '65706f7369745f646174756d29203d206f75747075742e646174756d00163017' + '303837540022a6606c0662c60740026601c03c6eb8c054c0d8dd50158a511533' + '034491626861735f76616c69645f74696d656465706f7369745f6f7574707574' + '28706172616d732c2072616e67652c206f7574707574732c20706172616d732e' + '74696d656465706f7369745f76616c696461746f722c2061646174616729203f' + '2046616c73650014a06eb8c050c0d4dd50040a9998191813981a1baa02813253' + '33033302700513333333323232322222222323232533304133333300b4a00100' + '08006004002266666601694401c01000c0080045282999820299982019baf330' + '0b375c604c60866ea8014025300103d879800013375e660160126eb8c088c10c' + 'dd5002a60103d879800014a02646608c0026608ca66608266ebc018014400440' + '14cc118c054cc118dd4804998231ba900a3304630233044375400c97ae04bd70' + '180a19822981398219baa0053304530263043375400a6608a6ea40252f5c02c6' + '018006601600644444464646464a666080602260846ea8c11801054ccc100008' + '4cdc78008048a5014a06eb8c114c118008c03cc100dd51822000982200099199' + '9800800a504a000a4444a666080606860846ea80044cc114ccc10000d30103d8' + '7a80004c0103d8798000330453330400024c0103d87a80004c0103d879800033' + '04530463043375400297ae013232323232323232323232323232323232533305' + '1533305101b1533305100e14a2201a29404c8c8c8c8c8c8cc170ccc15d4ccc15' + 'c03c528880526103d87a80004c0103d87980003305c333057533305700d14a22' + '01098103d87a80004c0103d87980003305c37526660466eb8c174008dd7182e8' + '009bae305d305e0014bd70182e800a99982aa99982a99baf00400214a2202226' + '464660b86ea4c090010cc170c174004cc170c174c1780052f5c0a646660aea66' + '60ae60020182600200e29404cc170dd48011982e1ba90074bd700a99982ba999' + '82b99982b98008062504a22600200e29404cc170dd48061982e1ba90024bd700' + 'b1b8f48920e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca49599' + '1b7852b85500333021302200148920e3b0c44298fc1c149afbf4c8996fb92427' + 'ae41e4649b934ca495991b7852b85500488120e3b0c44298fc1c149afbf4c899' + '6fb92427ae41e4649b934ca495991b7852b855001533305501213305a3752604' + '4008660b46ea4028cc168dd4802a5eb8058c168c16c008c164004c16405cc15c' + '0584cc158ccc1454ccc1440245288802260103d87a80004c0103d87980003305' + '6333051533305100714a2200498103d87a80004c0103d8798000330563752666' + '03a02000c00297ae0375c60ac60ae004604060a26ea8c154004c154008c078c1' + '3cdd518298009999808008003803182918298051bae30513052002301b304c37' + '5460a000260a0004603260946ea8c138004cccc02c02c008004c1340154ccc11' + '8020528880129998228040a5110023371e00601466e3c008028c124008dd7182' + '380098219baa001225333037337200040022980103d8798000153330373371e0' + '040022980103d87a800014c103d87b8000222372466e2800ccdc500100091b92' + '337146eb8c06cc0dcdd500099b8a375c6034606e6ea8004dd7180b181b9baa00' + '1375c602a606c6ea8024dd71801981b1baa009375c6004606c6ea8044dd71801' + '181b1baa009301a303637540026032606c6ea8004c054c0d8dd50008a5030383' + '03537540502a66606466e1c011200114a029405281181c181c981c981c981c80' + '09181b981c181c181c0009b8848000528199800991980080080c11299981a000' + '8a5eb7bdb1804c8c8c8c94ccc0d0cdc8a44100002153330343371e9101000021' + '0031005133039337606ea4008dd3000998030030019bab3036003375c6068004' + '6070004606c0020386eb8c040c0c4dd5002111192999818981298199baa00114' + '80004dd6981b981a1baa0013253330313025303337540022980103d87a800013' + '23300100137566070606a6ea8008894ccc0dc004530103d87a80001323232325' + '333037337220100042a66606e66e3c0200084c02ccc0f0dd4000a5eb80530103' + 'd87a8000133006006003375a60720066eb8c0dc008c0ec008c0e4004c8cc0040' + '04010894ccc0d80045300103d87a80001323232325333036337220100042a666' + '06c66e3c0200084c028cc0ecdd3000a5eb80530103d87a800013300600600337' + '5660700066eb8c0d8008c0e8008c0e0004dd2a40006601000204ea6666660640' + '0220022a6605604c2c2a6605604c2c2a6605604c2c2a6605604c2c605e60586e' + 'a800454cc0a924013665787065637420496e6c696e65446174756d286e65775f' + '737461746529203d2076616c696461746f725f6f75747075742e646174756d00' + '16300a302b37540022a660520462c605a002660020226eb8c030c0a4dd500f11' + '198060011192999814980e98159baa00113371e0066eb8c0bcc0b0dd50008a50' + '300f302b3754601e60566ea8004cc004008078894ccc094c068c09cdd5001099' + '29998150008a998138010b0991929998160008a998148020b099192999817000' + '8a998158030b0991929998180008a998168040b0991929998190008a99817805' + '0b09919299981a0008a998188060b099299981a981c00109924c64a666064604' + 'e0022a66606c606a6ea8028526153303300e1615333032302600115333036303' + '537540142930a998198070b0a998198070b18199baa009153303200d16325333' + '333039001153303200d16153303200d16153303200d16153303200d161375c00' + '2606c002606c00464a66666606e0022a660600162c2a660600162c2a66060016' + '2c2a660600162c26eb8004c0d0004c0d0008c94cccccc0d400454cc0b8024585' + '4cc0b80245854cc0b80245854cc0b8024584dd70009819000981900119299999' + '98198008a998160038b0a998160038b0a998160038b0a998160038b09bae0013' + '03000130300023253333330310011001153302a00516153302a00516153302a0' + '0516153302a00516302e001302e00232533333302f0011533028003161533028' + '003161533028003161375a0022a660500062c605800260506ea800854cc09800' + '4594cccccc0a8004400454cc08c0705854cc08c0705854cc08c0705854cc08c0' + '7058c09cc090dd50011b874801054cc08524013c65787065637420496e6c696e' + '65446174756d286f6c645f737461746529203d2076616c696461746f725f696e' + '7075742e6f75747075742e646174756d0016300130223754600a60446ea80088' + 'c094c098c09800454cc07c05c58c08c004cc88cc00c0088c94ccc080c050c088' + 'dd5000899b8f003375c604c60466ea8004528180318111baa300630223754600' + 'a60446ea8004dd618110049bae3002301f375402844646600200200644a66604' + '6002297ae0132325333021300500213302600233004004001133004004001302' + '7002302500123021302200123020001301e301f301f301f0023756603a002603' + 'a603a0046eb0c06c004c06cc06c004c058dd50019bae3018301537540022a660' + '26920121657870656374204d696e74286f776e5f706f6c69637929203d207075' + '72706f73650016301730180023016001301237540022930a998082491856616c' + '696461746f722072657475726e65642066616c73650013656323232533301030' + '050011325333015001153301200e16132533301630190021324994ccc048c01c' + 'c050dd5000899299980b8008a9980a0080b09919299980c8008a9980b0090b09' + '919299980d8008a9980c00a0b099299980e180f801099191924c6601a00602e6' + '601600802c6601400a02a2a6603202a2c64a66666604000220022a6603202a2c' + '2a6603202a2c2a6603202a2c2a6603202a2c603a002603a00464a66666603c00' + '220022a6602e0262c2a6602e0262c2a6602e0262c2a6602e0262c60360026036' + '00464a66666603800220022a6602a0222c2a6602a0222c2a6602a0222c2a6602' + 'a0222c6032002602a6ea800454cc04c03c5854cc04c03c58c94cccccc0680044' + '00454cc04c03c5854cc04c03c5854cc04c03c5854cc04c03c58c05c004c04cdd' + '50030a9998081802000899299980a8008a998090070b099299980b180c801099' + '24ca666024600e60286ea80044c94ccc05c00454cc050040584c8c94ccc06400' + '454cc058048584c8c94ccc06c00454cc060050584c8c94ccc07400454cc06805' + '8584c94ccc078c0840084c8c8c8c9263301000401a3300e0050193300d006018' + '3300c007017153301b017163253333330220011001153301b01716153301b017' + '16153301b01716153301b01716301f001301f002325333333020001100115330' + '1901516153301901516153301901516153301901516301d001301d0023253333' + '3301e0011001153301701316153301701316153301701316153301701316301b' + '001301b00232533333301c001100115330150111615330150111615330150111' + '61533015011163019001301537540022a6602601e2c2a6602601e2c64a666666' + '03400220022a6602601e2c2a6602601e2c2a6602601e2c2a6602601e2c602e00' + '260266ea801854cc04403458c044dd5002912999808180298091baa002132533' + '3015001153301200216132325333017001153301400416132325333019001153' + '301600616132533301a301d002149854cc05c01c58c94cccccc07800454cc05c' + '01c5854cc05c01c5854cc05c01c5854cc05c01c584dd7000980d800980d80119' + '299999980e0008a9980a8028b0a9980a8028b0a9980a8028b0a9980a8028b09b' + 'ae0013019001301900232533333301a001153301300316153301300316153301' + '3003161533013003161375c002602e00260266ea800854cc0440045888c8cc00' + '400400c88c94ccc048c01c0044c94ccc05c00454cc050014584c8c94ccc06400' + '454cc05801c584c8c94ccc06c00454cc060024584c94ccc070c07c0084c8c926' + '3300a00a00233009009003153301900a163253333330200011001153301900a1' + '6153301900a16153301900a16153301900a16301d001301d00232533333301e0' + '011001153301700816153301700816153301700816153301700816301b001301' + 'b00232533333301c001153301500616153301500616153301500616153301500' + '6161375c0026032002602a6ea800854ccc048c0180044c94ccc05c00454cc050' + '014584c94ccc060c06c00852615330150061632533333301c001153301500616' + '1533015006161533015006161533015006161375c0026032002602a6ea800854' + 'cc04c01058c04cdd50009b8748008dc3a4000a66666602400220022a6601600e' + '2c2a6601600e2c2a6601600e2c2a6601600e2c920165657870656374205b7661' + '6c696461746f725f696e7075745d203d0a20202020202066696e645f73637269' + '70745f696e7075747328696e707574733a20696e707574732c20736372697074' + '5f686173683a20706172616d732e73746174655f686f6c646572290049012365' + '7870656374206f6c645f73746174653a205374617465203d206f6c645f737461' + '746500490153657870656374205b76616c696461746f725f6f75747075745d20' + '3d0a20202020202066696e645f7363726970745f6f757470757473286f757470' + '7574732c20706172616d732e73746174655f686f6c6465722900490123657870' + '656374206e65775f73746174653a205374617465203d206e65775f7374617465' + '0049011072646d723a204d696e74416374696f6e00490139657870656374205b' + '6f75747075745d203d2066696e645f7363726970745f6f757470757473286f75' + '74707574732c20706f6c6963795f6964290049013e6578706563742074696d65' + '6465706f7369745f646174756d3a2054696d654465706f736974446174756d20' + '3d2074696d656465706f7369745f646174756d005734ae7155ceaab9e5573eae' + '815d0aba257489818dd8799f581cbd61dd11567ca6bb8e341e2cd5957cc50724' + '12525e43f145b6cd15b8581ca03469e9ffb55592d9badbd35de9cb6525517044' + '055cd7001818f640581cc4c5846d83cc27ad145ca5c7eba39734e8e7ae80c509' + '9d35f928669c1b00000192510f28031906d61a05265c00581cf0ff48bbb7bbe9' + 'd59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9aff0001', + ), + ), + ScriptRef( + PlutusV2Script.fromHex( + '5906280100003323232323232323232323232222323232322533300c32323253' + '3300f300730113754010266666002944028dd7180118091baa00b375a6006602' + '46ea802c0104c94ccc040c020c048dd5000899299980a8008a998090070b0991' + '9299980b8008a9980a0080b099299980c180d80109999980325000f003001009' + '15330150111632533333301c0011533015011161533015011161533015011161' + '375a0022a6602a0222c6032002603200464a6666660340022a6602601e2c2a66' + '02601e2c2a6602601e2c2a6602601e2c26eb8004c05c004c04cdd50008a99808' + '8068b299999980b80508050a998080060b0a998080060b0a998080060b0a9980' + '80060b1111119299980a299980a299980a19baf4c103d8798000005153330140' + '0614a22a6602a9210f69735f636f6c6c203f2046616c73650014a0264a66602a' + '6464a66602e601c60326ea80044c8c94ccc064c8c94ccc06cc04c0045288a999' + '80d9809000899299980e19299981098100008a99980e980a180f8008a5115333' + '01d3015301f00114a02c2c6ea8c040c07cdd51807980f9baa00713371200200a' + '266e20004014dd69810980f1baa00214a060386ea8004c030c070dd51806180e' + '1baa004100114a06464a6660346024002294054ccc068c0440044c94ccc06cc9' + '4ccc080c07c00454ccc070c04cc0780045288a99980e180a180f0008a5016163' + '754601e603c6ea8c03cc078dd5003099b890040011337100080026eb4c080c07' + '4dd50010a51301b3754002601660366ea8c030c06cdd50019bad301d301a3754' + '0022a660309201396578706563742046696e697465286c6f7765725f626f756e' + '6429203d2072616e67652e6c6f7765725f626f756e642e626f756e645f747970' + '650016300930193754601260326ea8c070c074c074c074c074c074c074c074c0' + '64dd51804980c9baa0043374a90001980d19ba548000cc068cdd2a4004660346' + 'ea00112f5c06603466602a94530103d87a80004c0103d87980004bd701980d26' + '010ad8799fd87b80d87a80ff004bd700a51153301649013e74696d655f656c61' + '7073656428636f6e746578742e7472616e73616374696f6e2e76616c69646974' + '795f72616e67652c2074696d6529203f2046616c73650014a064a66602a601a6' + '02e6ea80044c94ccc06800454cc05c054584c8c94ccc07000454cc06405c584c' + '94ccc074c080008526153301a01816325333333021001153301a01816153301a' + '01816153301a018161375a0022a660340302c603c002603c00464a66666603e0' + '022a6603002c2c2a6603002c2c2a6603002c2c2a6603002c2c26eb8004c07000' + '4c060dd50008a9980b00a0b299999980e00288028a9980a8098b0a9980a8098b' + '0a9980a8098b0a9980a8098b0a5115330154910d76616c6964203f2046616c73' + '650014a02a666028002294454cc0552401117369676e65645f6279203f204661' + '6c73650014a029414ccc04cc8cc004004dd6180d180d980d980d980d980d980d' + '980d980d980b9baa30073017375400444a66603200229404c94ccc058cdc79ba' + 'e301c00200614a22660060060026038002294454cc0512413d6c6973742e6861' + '7328636f6e746578742e7472616e73616374696f6e2e65787472615f7369676e' + '61746f726965732c206372656429203f2046616c73650014a046028002460266' + '0280022930a99806a4811856616c696461746f722072657475726e6564206661' + '6c7365001365632533300b30030011533300f300e37540082930a998060048b0' + 'a99980598010008a99980798071baa004149854cc0300245854cc03002458c03' + '0dd50019b8748008dc3a4000a66666601e00220022a6601000a2c2a6601000a2' + 'c2a6601000a2c2a6601000a2c921556578706563742054696d654465706f7369' + '74446174756d207b2062656e65666963696172792c20646561646c696e65207d' + '3a2054696d654465706f736974446174756d203d0a2020202020202020202064' + '6174756d0049011972646d723a2054696d654465706f73697452656465656d65' + '7200490123657870656374205f643a2054696d654465706f736974446174756d' + '203d20646174756d005734ae7155ceaab9e5573eae815d0aba257489812bd879' + '9f581cd5c9e4617211e2ff7c7fd01d0de9ac82e5bb81dc5a5e16b59911866a1b' + '00000192eb8df003ff0001', + ), + ), +]; diff --git a/catalyst_voices_packages/catalyst_cardano_serialization/test/transaction_output_test.dart b/catalyst_voices_packages/catalyst_cardano_serialization/test/transaction_output_test.dart index a55aa5f6d1..1bb4512983 100644 --- a/catalyst_voices_packages/catalyst_cardano_serialization/test/transaction_output_test.dart +++ b/catalyst_voices_packages/catalyst_cardano_serialization/test/transaction_output_test.dart @@ -15,7 +15,7 @@ void main() { final datumData = Data(CborBytes(Uint8List.fromList([1, 2, 3, 4, 5, 6]))); final datumOptionHash = DatumOption(datumHash); final datumOptionData = DatumOption(datumData); - final script = PlutusV2Script(Uint8List.fromList([0x43, 0x01, 0x02, 0x03])); + final script = PlutusV2Script.fromHex('43010203'); final scriptRef = ScriptRef(script); group('TransactionOutput encoding/decoding Tests', () { diff --git a/docs/Earthfile b/docs/Earthfile index 79eb3c40d2..741d0f4867 100644 --- a/docs/Earthfile +++ b/docs/Earthfile @@ -1,6 +1,6 @@ VERSION 0.8 -IMPORT github.com/input-output-hk/catalyst-ci/earthly/docs:v3.2.03 AS docs-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/docs:v3.2.07 AS docs-ci IMPORT .. AS repo IMPORT ../catalyst-gateway AS catalyst-gateway diff --git a/docs/blueprint.cue b/docs/blueprint.cue new file mode 100644 index 0000000000..d5db755573 --- /dev/null +++ b/docs/blueprint.cue @@ -0,0 +1,2 @@ +version: "1.0.0" +project: name: "voices-docs" diff --git a/docs/src/architecture/08_concepts/voting_transaction/crypto.md b/docs/src/architecture/08_concepts/voting_transaction/crypto.md index f8dd29a8cf..5ae02eeaa1 100644 --- a/docs/src/architecture/08_concepts/voting_transaction/crypto.md +++ b/docs/src/architecture/08_concepts/voting_transaction/crypto.md @@ -28,7 +28,7 @@ and performing tally process for executing "Catalyst" fund events. ### Preliminaries -Through this paper we will use the following notations to refer to some entities of this protocol: +The protocol is based around the following entities: * **Proposal** - voting subject on which each voter will be cast their votes. @@ -49,13 +49,9 @@ Through this paper we will use the following notations to refer to some entities Or it could be defined based on their stake in the blockchain, which is more appropriate for web3 systems. -Important to note that current protocol defined to work with the one specific proposal, -so all definitions and procedures would be applied for some proposal. +Important to note that the protocol defined for some **one** proposal. Obviously, it could be easily scaled for a set of proposals, -performing all this protocol in parallel. - -The voting committee and voters registration/definition -are not subjects of this document. +performing protocol steps in parallel. ### Initial setup @@ -125,10 +121,10 @@ components would be defined as follows: -After the choice is done, -vote **must** be encrypted using shared shared election public key $pk$. +After the choice is done (described in [section](#voting-choice)), +vote **must** be encrypted using shared election public key $pk$. -Lifted ElGamal encryption algorithm is used, +To achieve that, Lifted ElGamal encryption algorithm is used, noted as $ElGamalEnc(message, randomness, public \; key)$. More detailed description of the lifted ElGamal algorithm you can find in the [appendix B](#b-lifted-elgamal-encryptiondecryption). @@ -138,17 +134,16 @@ $ElGamalEnc(message, randomness, public \; key)$ algorithm produces a ciphertext c = ElGamalEnc(message, randomness, public \; key) \end{equation} -To encode previously generated unit vector $\mathbf{e}_i$ ($i$ - voting choice identifier), -in more details you can read in this [section](#voting-choice), +To encrypt previously generated unit vector $\mathbf{e}_i$ ($i$ - voting choice identifier), for each vector component value $e_{i,j}$ generate a corresponding randomness.
Lets denote randomness value as $r_j$, -where $j$ states as the same identifier of the vector component $e_{i,j}$. +where $j$ is the same vector component's index $j$ value, $e_{i, j} => r_j$. -Then, for each vector component $e_{i,j}$ with the corresponding randomness, +Then, for each vector component $e_{i,j}$ with the corresponding randomness $r_j$, perform encryption algorithm applying shared election public key $pk$. \begin{equation} -c_j = Enc(e_{i,j}, r_j, pk) +c_j = ElGamalEnc(e_{i,j}, r_j, pk) \end{equation} As a result getting a vector $\mathbf{c}$ of ciphertext values $c_f$, @@ -156,10 +151,10 @@ with the size equals of the size $\mathbf{e}_t$ unit vector, equals to the amount of the voting options. Lets denote this vector as: \begin{equation} -\mathbf{c} = (c_1, \ldots, c_{M}) +\mathbf{c} = (c_1, \ldots, c_{M}) = (ElGamalEnc(e_{i,j}, r_j, pk), \ldots, ElGamalEnc(e_{i,M}, r_M, pk)) \end{equation} -where $M$ is the voting options amount. +where $M$ is the voting options amount and $i$ is the index of the voting choice. This is a first part of the published vote for a specific proposal. @@ -171,7 +166,7 @@ After the voter's choice is generated and encrypted, it is crucial to prove that [encoding](#voting-choice) and [encryption](#vote-encrypting) are formed correctly (i.e. that the voter indeed encrypt a unit vector). -Because by the definition of the encryption algorithm $Enc(message, randomness, public \; key)$ +Because by the definition of the encryption algorithm $ElGamalEnc(message, randomness, public \; key)$ it is possible to encrypt an any message value, it is not restricted for encryption only $0$ and $1$ values (as it was stated in the previous [section](#voting-choice), @@ -181,22 +176,28 @@ so everyone could validate a correctness of the encrypted vote data, without revealing a voting choice itself. To achieve that a some sophisticated ZK (Zero Knowledge) algorithm is used, -noted as $VotingChoiceProof(\mathbf{c})$. -It takes an encrypted vote vector $\mathbf{c}$ and generates a proof value $\pi$. +noted as $VoteProof(\mathbf{c}, \mathbf{e}_i, \mathbf{r}, pk)$. +It takes an encrypted vote vector $\mathbf{c}$, +an original vote unit vector $\mathbf{e}_i$, +a randomness vector $\mathbf{r}$, +which was used during encryption algorithm $ElGamalEnc$ +and an shared election public key $pk$. +As a result it generates a proof value $\pi$. \begin{equation} -\pi = VotingChoiceProof(\mathbf{c}) +\pi = VoteProof(\mathbf{c}, \mathbf{e}_i, \mathbf{r}, pk) \end{equation} -So to validate a $VotingChoiceCheck(\mathbf{c}, \pi)$ procedure should be used, -which takes an encrypted vote $\mathbf{c}$ and corresponded proof $\pi$ +So to validate a $VoteCheck(\mathbf{c}, \pi, pk)$ procedure should be used, +which takes an encrypted vote $\mathbf{c}$, corresponded proof $\pi$ +and the same hared election public key $pk$ as arguments and returns `true` or `false`, is it valid or not. \begin{equation} -true | false = VotingChoiceCheck(\mathbf{c}, \pi) +true | false = VoteCheck(\mathbf{c}, \pi, pk) \end{equation} -A more detailed description of how $VotingChoiceProof$, $VotingChoiceCheck$ work -you can find in the section *2.4* of this [paper][treasury_system_spec]. +A more detailed description of how $VoteProof$, $VoteCheck$ work +you can find in the [appendix D](#d-non-interactive-zk-vote-proof). #### Vote publishing @@ -305,7 +306,7 @@ Which proofs that a provided encrypted tally result value $er$ was decrypted int using the exact secret key $sk$, which is corresponded to the already known shared election public key $pk$. \begin{equation} -\pi = TallyProof(er, r, sk) +\pi = TallyProof(er, sk) \end{equation} So to validate a $TallyCheck(er, r, pk, \pi)$ procedure should be used, @@ -317,10 +318,8 @@ is it valid or not. true | false = TallyCheck(er, r, pk, \pi) \end{equation} - A more detailed description of how $TallyProof$, $TallyCheck$ work -you can find in the section *Fig. 10* of this [paper][treasury_system_spec]. - +you can find in the [appendix E](#e-non-interactive-zk-tally-proof). #### Tally publishing @@ -331,7 +330,7 @@ and tally proofs $\pi_i$ corresponded for each voting option of some proposal. It could be published using any public channel, e.g. blockchain, ipfs or through p2p network. -## A: Group definition +## A: Group Definition @@ -350,7 +349,7 @@ And defined as follows: -## B: Lifted ElGamal encryption/decryption +## B: Lifted ElGamal Encryption/Decryption @@ -363,12 +362,16 @@ More detailed how group operations are defined, described in [appendix A](#a-gro ### Encryption Lifted ElGamal encryption algorithm -takes as arguments $m$ message ($m \in \mathbb{Z}_q^*$), -$r$ randomness ($r \in \mathbb{Z}_q^*$), -$pk$ public key ($pk \in \mathbb{G}$): +takes as arguments: + +* $m$ - message ($m \in \mathbb{Z}_q$) +* $r$ - randomness ($r \in \mathbb{Z}_q$) +* $pk$ - public key ($pk \in \mathbb{G}$) + \begin{equation} ElGamalEnc(m, r, pk) = (c_1, c_2) = c, \end{equation} + \begin{equation} c_1 = g^r, \quad c_2 = g^m \circ pk^r \end{equation} @@ -377,8 +380,11 @@ $c$ - is a resulted ciphertext which consists of two elements $c_1, c_2 \in \mat ### Decryption -Lifted ElGamal decryption algorithm takes as arguments $c$ ciphertext, -$sk$ secret key ($sk \in \mathbb{Z}_q^*$): +Lifted ElGamal decryption algorithm takes as arguments: + +* $c$ - ciphertext, +* $sk$ - secret key ($sk \in \mathbb{Z}_q$) + \begin{equation} ElGamalDec(c, sk) = Dlog(c_2 \circ c_1^{-sk}) = m \end{equation} @@ -387,11 +393,11 @@ $m$ - an original message which was encrypted on the previous step, $Dlog(x)$ is a discrete logarithm of $x$. Note that since $Dlog$ is not efficient, the message space should be a small set, -say $m \in {{0,1}}^{\xi}$, for $\xi \le 30$. +say $m \in \{0,1\}^{\xi}$, for $\xi \le 30$. -## C: Homomorphic tally +## C: Homomorphic Tally @@ -399,11 +405,14 @@ Homomorphic tally schema is defined over any cyclic group $\mathbb{G}$ of order
More detailed how group operations are defined, described in [appendix A](#a-group-definition). -Homomorphic tally algorithm takes as arguments $i$ voting choice index, -$[\mathbf{c_1}, \mathbf{c_2}, \ldots, \mathbf{c_N}]$ -an array of encrypted votes vector's, -$[\alpha_1, \alpha_2, \ldots, \alpha_N]$ - an array of corresponded voter's voting power. -Where $N$ - votes amount. +Homomorphic tally algorithm takes as arguments: + +* $i$ - voting choice index +* $[\mathbf{c_1}, \mathbf{c_2}, \ldots, \mathbf{c_N}]$ - an array of encrypted votes vector's, + where $N$ - votes amount +* $[\alpha_1, \alpha_2, \ldots, \alpha_N]$ - an array of corresponded voter's voting power, + where $N$ - votes amount + \begin{equation} Tally(i, [\mathbf{c_1}, \mathbf{c_2}, \ldots, \mathbf{c_N}], [\alpha_1, \alpha_2, \ldots, \alpha_N]) = c_{1, i}^{\alpha_1} \circ c_{2, i}^{\alpha_2} \circ \ldots \circ c_{N, i}^{\alpha_N} = er_i @@ -419,6 +428,229 @@ it needs a decryption procedure corresponded for which encryption one was made. +## D: Non-Interactive ZK Vote Proof + +Non-Interactive ZK (Zero Knowledge) Vote Proof algorithm helps to solve only one problem, +to prove that the encrypted voting choice is exactly a some unit vector, +which consists of **only one** is $1$ value and others are $0$. + +A more detailed and formal description +you can find in the section *2.4* of this [paper][treasury_system_spec]. + +It is assumed that the original encryption and decryption is performing by ElGamal scheme. +It means that all described operations is also group dependent +(more about groups described in [appendix A](#a-group-definition)). + +### Prover + +The prover algorithm takes as arguments: + +* $\mathbf{c} = (c_0, \ldots, c_{M-1})$ - encrypted vote (a vector of ciphertext), + where $M$ is amount of voting options. +* $\mathbf{e}_i = (e_{i,0},\ldots, e_{i,M-1})$ - original voting choice, a unit vector, + where $M$ is amount of voting options + and $i$ is an index of the voting choice. +* $\mathbf{r} = (r_0, \ldots, r_{M-1})$ - a vector of randomnesses, + which was used during encryption. +* $pk$ - is a public key, which was used to encrypt a unit vector. + +So basically here is the relation between all these values: +\begin{equation} +\mathbf{c} = (c_1, \ldots, c_M) = (ElGamalEnc(e_{i,1}, r_1, pk), \ldots, ElGamalEnc(e_{i,M}, r_M, pk)) +\end{equation} + +\begin{equation} +VoteProof(\mathbf{c}, \mathbf{e}_i, \mathbf{r}, pk) = \pi +\end{equation} + +Important to note that the following notation would be used +$\{a_i\}$ - which is a set of some elements $a_i$. + +$\pi$ is the final proof. +To compute it, prover needs to perform the next steps: + +1. If the number of voting options $M$ is not a perfect power of $2$, + extend the vector $\mathbf{c}$ with $c_j = ElGamalEnc(0, 0, pk)$, + where $N$ is a perfect power of $2$, $j \in [M, \ldots, N - 1]$. + So the resulted $\mathbf{c} = (c_1, \ldots, c_M, \{c_j\})$. +2. Generate a commitment key $ck \in \mathbb{G}$. +3. Let $i_k$ is a bit value of the $i$-th binary representation, + where $k \in [0, log_2(N) - 1]$. + E.g. $i=3$ and $N=8, log_2(N) = 3$, + its binary representation $i=011$, + $i_0=0, i_1=1, i_2=1$. +4. For $l \in [0, \ldots, log_2(N)-1]$ generate a random values + $\alpha_l, \beta_l, \gamma_l, \delta_l, \in \mathbb{Z}_q$. +5. For $l \in [0, \ldots, log_2(N)-1]$ calculate, where $g$ is the group generator: + * $I_l = g^{i_l} \circ ck^{\alpha_l}, I_l \in \mathbb{G}$. + * $B_l = g^{\beta_l} \circ ck^{\gamma_l}, B_l \in \mathbb{G}$. + * $A_l = g^{i_l * \beta_l} \circ ck^{\delta_l}, A_l \in \mathbb{G}$. +6. Calculate a first verifier challenge + $com_1 = H(ck, pk, \{c_j\}, \{I_l\}, \{B_l\}, \{A_l\})$, + where $H$ is a hash function, + $j \in [0, \ldots, N-1]$ + and $l \in [0, \ldots, log_2(N)-1]$. +7. For $j \in [0, \ldots, N-1]$ calculate polynomials + in the following form $p_j(x) = e_{i, j}*x^{log_2(N)} + \sum_{l=0}^{log_2(N)-1} p_{j,l} * x^l$: + * $j_l$ is a bit value of the $j$-th binary representation (same as was described in step `3`). + * $z_l^{1} = i_l * x + \beta_l$. + * $z_l^{0} = x - z_l^{1} = (1 - i_l)*x - \beta_l$. + * Calculate the polynomial itself $p_j(x) = \prod_{l=0}^{log_2(N)-1} z_l^{j_l}$ +8. For $l \in [0, \ldots, log_2(N)-1]$ generate a random $R_l \in \mathbb{Z}_q$. +9. For $l \in [0, \ldots, log_2(N)-1]$ compute + $D_l = ElGamalEnc(sum_l, R_l, pk)$, + where $sum_l = \sum_{j=0}^{N-1}(p_{j,l} * com_1^j)$ + and $p_{j,l}$ - corresponding coefficients of the polynomial $p_j(x)$ calculated on step `7`. +10. Calculate a second verifier challenge + $com_2 = H(com_1, \{D_l\})$, + where $H$ is a hash function + and $l \in [0, \ldots, log_2(N)-1]$. +11. For $l \in [0, \ldots, log_2(N)-1]$ calculate: + * $z_l = i_l * com_2 + \beta_l, z_l \in \mathbb{Z}_q$. + * $w_l = \alpha_l * com_2 + \gamma_l, w_l \in \mathbb{Z}_q$. + * $v_l = \alpha_l * (com_2 - z_l) + \delta_l, v_l \in \mathbb{Z}_q$. +12. Calculate + $R=\sum_{j=0}^{N-1}(r_j * (com_2)^{log_2(N)} * (com_1)^j) + \sum_{l=0}^{log_2(N)-1}(R_l * (com_2)^l)$, + where $r_j$ original random values which was used to encrypt $c_j$ + and $R_l$ random values generated in step `8`. + +Finally, the proof is $\pi = (ck, \{I_l\}, \{B_l\}, \{A_l\}, \{D_l\}, \{z_l\}, \{w_l\}, \{v_l\}, R)$, +where $l \in [0, \ldots, log_2(N)-1]$. + +### Verifier + +The verifier algorithm takes as arguments: + +* $\mathbf{c} = (c_0, \ldots, c_{M-1})$ - encrypted vote (a vector of ciphertext), + where $M$ is amount of voting options. +* $\pi$ - a prover's proof generated on the [previous step](#prover) +* $pk$ - is a public key, which was used to encrypt a unit vector. + +\begin{equation} +VoteCheck(\mathbf{c}, \pi, pk) = true | false +\end{equation} + +As a result algorithm will return `true` or `false`, +is the verification was succeeded or not respectively. + +Knowing that $\pi$ equals to $(ck, \{I_l\}, \{B_l\}, \{A_l\}, \{D_l\}, \{z_l\}, \{w_l\}, \{v_l\}, R)$, +verifier needs to perform the next steps: + +1. If the number of voting options $M$ is not a perfect power of $2$, + extend the vector $\mathbf{c}$ with $c_j = ElGamalEnc(0, 0, pk)$, + where $N$ is a perfect power of $2$, $j \in [M, \ldots, N - 1]$. + So the resulted $\mathbf{c} = (c_1, \ldots, c_M, \{c_j\})$. +2. Calculate the first verifier challenge + $com_1 = H(ck, pk, \{c_j\}, \{I_l\}, \{B_l\}, \{A_l\})$, + where $H$ is a hash function, + $j \in [0, \ldots, N-1]$ + and $l \in [0, \ldots, log_2(N)-1]$. +3. Calculate a second verifier challenge + $com_2 = H(com_1, \{D_l\})$, + where $H$ is a hash function + and $l \in [0, \ldots, log_2(N)-1]$. +4. For $l \in [0, \ldots, log_2(N)-1]$ verify that the following statements are `true`, + where $g$ is the group generator: + * $(I_l)^{com_2} \circ B_l == g^{z_l} \circ ck^{w_l}$. + * $(I_l)^{com_2 - z_l} \circ A_l == g^{0} \circ ck^{v_l}$. +5. Calculate the following $Left = ElGamalEnc(0, R, pk)$. + Note that the $Left$ is a ciphertext, $Left = (Left_1, Left_2)$. +6. Note that $D_l$ is a ciphertext, + $D_l = (D_{l,1}, D_{l,2})$, for $l \in [0, \ldots, log_2(N)-1]$ + calculate the following: + * $Right2_1 = (D_{0,1})^{0} \circ \ldots \circ (D_{log_2(N) - 1,1})^{log_2(N) - 1}$. + * $Right2_2 = (D_{0,2})^{0} \circ \ldots \circ (D_{log_2(N) - 1,2})^{log_2(N) - 1}$. +7. For $j \in [0, \ldots, N-1]$ calculate the $p_j(com_2)$, + where $p_j$ is a prover's defined polynomial defined in step `7`: + * $j_l$ is a bit value of the $j$-th binary representation. + * $z_l^1 = z_j$. + * $z_l^0 = com_2 - z_j^1$. + * $p_j(com_2) = \prod_l^{log_2(N)-1} z_l^{j_l}$. +8. For $j \in [0, \ldots, N-1]$ calculate the $P_j = ElGamalEnc(-p_j(com_2), 0, pk)$. + Note that the $P_j$ is a ciphertext, $P_j = (P_{j,1}, P_{j,2})$. +9. Note that $C_j$ is a ciphertext, + $C_j = (C_{j,1}, C_{j,2})$, for $j \in [0, \ldots, N-1]$ + calculate: + * $Right1_{j,1} = (C_{j,1})^{com_2^{log_2(N)}} \circ (P_{j,1})^{com_1^{j}}$. + * $Right1_{j,2} = (C_{j,2})^{com_2^{log_2(N)}} \circ (P_{j,2})^{com_1^{j}}$. + * $Right1_{1} = Right1_{j,1} \circ \ldots \circ Right1_{N - 1, 1}$. + * $Right1_{2} = Right1_{j,2} \circ \ldots \circ Right1_{N - 1, 2}$. +10. Verify that the following statements are `true`: + * $Right1_{1} \circ Right2_1 == Left_1$. + * $Right1_{2} \circ Right2_2 == Left_2$. + +If step `4` and `10` returns `true` so the final result is `true` otherwise return `false`. + +## E: Non-Interactive ZK Tally Proof + +Non-Interactive ZK (Zero Knowledge) Tally Proof algorithm helps to solve only one problem, +to prove that the specific encrypted message was decrypted into the specific resulted value, +using exactly that secret key, +which is corresponds to the some shared public key. + + +A more detailed and formal description +you can find in the sections *Fig. 10* and *2.1.5* of this [paper][treasury_system_spec]. + + +It is assumed that the original encryption and decryption is performing by ElGamal scheme. +It means that all described operations is also group dependent +(more about groups described in [appendix A](#a-group-definition)). + +### Prover + +The prover algorithm takes as arguments: + +* $enc$ - an encrypted message (ciphertext). +* $sk$ - a secret key which was used to decrypt a message $enc$. + +\begin{equation} +TallyProof(enc, sk) = \pi +\end{equation} + +$\pi$ is the final proof. +To compute it, prover needs to perform the next steps: + +1. Take the first element of the ciphertext $enc = (enc_1, enc_2)$ + and calculate $d = enc_1^{sk}$. +2. Generate a random value $\mu, \quad \mu \in \mathbb{Z}_q$. +3. Compute $A_1 = g^{\mu}$, where $g$ is the group generator ($A_1 \in \mathbb{G}$). +4. Compute $A_2 = (enc_1)^{\mu}, \quad A_2 \in \mathbb{G}$. +5. Compute $e = H(pk, d, g, enc_1, A_1, A_2 )$, + where $pk$ is a corresponding public key of $sk$, $H$ is a hash function. +6. Compute $z = sk * e + \mu, \quad z \in \mathbb{Z}_q$. + +Finally, the proof is $\pi = (A_1, A_2, z)$. + +### Verifier + +The verifier algorithm takes as arguments: + +* $enc$ - an encrypted message (ciphertext). +* $dec$ - a decrypted message from the encrypted ciphertext $enc$. +* $pk$ - a public key corresponded to the $sk$ + which was supposedly used to decrypt a message $enc$. +* $\pi$ - a prover's proof generated on the [previous step](#prover-1). + +\begin{equation} +TallyCheck(enc, dec, pk, \pi) = true | false +\end{equation} + +As a result algorithm will return `true` or `false`, +is the verification was succeeded or not respectively. + +Knowing that $\pi$ equals to $(A_1, A_2, z)$, +verifier needs to perform the next steps: + +1. Take the first and second elements $enc_1, enc_2$ + of the ciphertext $enc = (enc_1, enc_2)$. +2. Calculate $d = g^{dec} \circ (-enc_2), \quad d \in \mathbb{G}$. +3. Compute $e = H(pk, d, g, enc_1, A_1, A_2 )$, where $g$ is the group generator. +4. Verify $g^z == pk^e \circ A_1$. +5. Verify $enc_1^z == d^e \circ A_2$. + +If step `3` and `4` returns `true` so the final result is `true` otherwise return `false`. + ## Rationale ## Path to Active diff --git a/melos.yaml b/melos.yaml index 2e38ced9f7..cc86a5442f 100644 --- a/melos.yaml +++ b/melos.yaml @@ -8,6 +8,67 @@ packages: - catalyst_voices_packages/** - utilities/** +permittedLicenses: + - MIT + - Apache-2.0 + - Unicode-DFS-2016 + - BSD-3-Clause + - BSD-2-Clause + - BlueOak-1.0.0 + - Apache-2.0 WITH LLVM-exception + - CC0-1.0 + +packageLicenseOverride: + fuchsia_remote_debug_protocol: BSD-3-Clause + flutter_driver: BSD-3-Clause + flutter_localizations: BSD-3-Clause + flutter_test: BSD-3-Clause + flutter_web_plugins: BSD-3-Clause + integration_test: BSD-3-Clause + rxdart: Apache-2.0 + vector_math: Apache-2.0 + sky_engine: Apache-2.0 + flutter_gen: MIT + # catalyst packages + catalyst_analysis: Apache-2.0 + catalyst_cose: Apache-2.0 + catalyst_cardano_serialization: Apache-2.0 + catalyst_cardano_platform_interface: Apache-2.0 + catalyst_compression: Apache-2.0 + catalyst_compression_web: Apache-2.0 + catalyst_cardano_web: Apache-2.0 + catalyst_compression_platform_interface: Apache-2.0 + catalyst_voices_assets: Apache-2.0 + catalyst_voices_assets_example: Apache-2.0 + catalyst_cardano: Apache-2.0 + catalyst_cardano_example: Apache-2.0 + catalyst_voices_localization: Apache-2.0 + catalyst_voices_models: Apache-2.0 + example: Apache-2.0 + catalyst_voices_remote_widgets: Apache-2.0 + remote_widgets_example: Apache-2.0 + catalyst_voices_view_models: Apache-2.0 + catalyst_voices_blocs: Apache-2.0 + catalyst_voices_shared: Apache-2.0 + catalyst_voices_brands: Apache-2.0 + catalyst_voices_services: Apache-2.0 + poc_local_storage: Apache-2.0 + catalyst_voices_repositories: Apache-2.0 + catalyst_voices: Apache-2.0 + uikit_example: Apache-2.0 + +packageSourceOverride: + fuchsia_remote_debug_protocol: https://github.com/flutter/flutter/blob/main/LICENSE + flutter_driver: https://github.com/flutter/flutter/blob/main/LICENSE + flutter_localizations: https://github.com/flutter/flutter/blob/main/LICENSE + flutter_test: https://github.com/flutter/flutter/blob/main/LICENSE + flutter_web_plugins: https://github.com/flutter/flutter/blob/main/LICENSE + integration_test: https://github.com/flutter/flutter/blob/main/LICENSE + rxdart: https://github.com/ReactiveX/rxdart/blob/master/LICENSE + vector_math: https://github.com/google/vector_math.dart/blob/master/LICENSE + sky_engine: https://github.com/flutter/engine/blob/main/sky/packages/sky_engine/LICENSE + flutter_gen: https://github.com/FlutterGen/flutter_gen/blob/main/LICENSE + command: version: linkToCommits: true @@ -19,14 +80,17 @@ command: flutter: ">=3.24.1" dependencies: asn1lib: ^1.5.3 + bip39: ^1.0.6 bloc_concurrency: ^0.2.2 collection: ^1.18.0 cryptography: ^2.7.0 + ed25519_hd_key: ^2.3.0 equatable: ^2.0.5 flutter_bloc: ^8.1.5 flutter_localized_locales: ^2.0.5 flutter_quill: ^10.5.13 flutter_quill_extensions: ^10.5.13 + flutter_secure_storage: ^9.2.2 formz: ^0.7.0 intl: ^0.19.0 logging: ^1.2.0 @@ -52,6 +116,23 @@ command: mocktail: ^1.0.1 scripts: + l10n: + run: | + melos exec -c 1 --scope="catalyst_voices_localization" -- flutter gen-l10n + description: | + Run `flutter gen-l10n` in catalyst_voices_localization package to generate l10n bindings. + + build_runner: + run: | + melos exec -c 1 \ + --depends-on="build_runner" \ + --ignore="catalyst_voices_services" -- \ + dart run build_runner build --delete-conflicting-outputs + description: | + Run `build_runner` in every package which contains the build_runner dependency. + The catalyst_voices_services is skipped because to run a build_runner there you + must generate first swagger docs (see related Earthfile). + metrics: run: | melos exec -c 1 -- \ @@ -74,6 +155,11 @@ scripts: melos exec -c 1 --dir-exists="integration_test" -- "find . -name "*.dart" ! -name "*.g.dart" ! -path '*/generated/*' | tr '\n' ' ' | xargs dart format --output none --set-exit-if-changed" description: Run `dart format` checks for all packages. + license-check: + run: | + melos exec -- lic_ck check-licenses -c $MELOS_ROOT_PATH/melos.yaml -i + description: Run `lic_ck` checks for all packages. + test:select: run: | melos exec -c 1 --dir-exists="test" -- flutter test diff --git a/utilities/local-cluster/Earthfile b/utilities/local-cluster/Earthfile index d046ba9a5d..85a5885dde 100644 --- a/utilities/local-cluster/Earthfile +++ b/utilities/local-cluster/Earthfile @@ -30,7 +30,7 @@ cluster-test: # kubernetes-base : base container with tooling set up for local access kubernetes-base: - FROM alpine:3.19 + FROM alpine:3.20.3 # Install kubectl RUN apk update && \ diff --git a/utilities/wallet-tester/blueprint.cue b/utilities/wallet-tester/blueprint.cue new file mode 100644 index 0000000000..263a5d2095 --- /dev/null +++ b/utilities/wallet-tester/blueprint.cue @@ -0,0 +1,2 @@ +version: "1.0.0" +project: name: "voices-wallet-tester"