diff --git a/Cargo.lock b/Cargo.lock index de396ed592..10582a60e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -617,6 +617,7 @@ dependencies = [ name = "coconut-bandwidth-contract-common" version = "0.1.0" dependencies = [ + "cosmwasm-std", "schemars", "serde", ] @@ -904,6 +905,7 @@ dependencies = [ "clap 3.1.8", "coconut-bandwidth-contract-common", "coconut-interface", + "credential-storage", "credentials", "crypto", "network-defaults", @@ -917,6 +919,18 @@ dependencies = [ "validator-client", ] +[[package]] +name = "credential-storage" +version = "0.1.0" +dependencies = [ + "async-trait", + "log", + "nymcoconut", + "sqlx", + "thiserror", + "tokio", +] + [[package]] name = "credentials" version = "0.1.0" @@ -1929,8 +1943,9 @@ dependencies = [ name = "gateway-client" version = "0.1.0" dependencies = [ + "async-trait", "coconut-interface", - "cosmrs 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)", + "credential-storage", "credentials", "crypto", "fluvio-wasm-timer", @@ -3034,6 +3049,7 @@ dependencies = [ "client-core", "coconut-interface", "config", + "credential-storage", "credentials", "crypto", "dirs", @@ -3173,6 +3189,7 @@ dependencies = [ "client-core", "coconut-interface", "config", + "credential-storage", "credentials", "crypto", "dirs", @@ -3213,6 +3230,7 @@ dependencies = [ "coconut-interface", "config", "console-subscriber", + "credential-storage", "credentials", "crypto", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 43e8273e46..fb373b4ed8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "common/client-libs/gateway-client", "common/client-libs/mixnet-client", "common/client-libs/validator-client", + "common/credential-storage", "common/coconut-interface", "common/config", "common/credentials", diff --git a/clients/client-core/src/config/mod.rs b/clients/client-core/src/config/mod.rs index 928727a76c..4bb969205f 100644 --- a/clients/client-core/src/config/mod.rs +++ b/clients/client-core/src/config/mod.rs @@ -103,15 +103,8 @@ impl Config { self::Client::::default_reply_encryption_key_store_path(&id); } - #[cfg(not(feature = "coconut"))] - if self - .client - .backup_bandwidth_token_keys_dir - .as_os_str() - .is_empty() - { - self.client.backup_bandwidth_token_keys_dir = - self::Client::::default_backup_bandwidth_token_keys_dir(&id); + if self.client.database_path.as_os_str().is_empty() { + self.client.database_path = self::Client::::default_database_path(&id); } self.client.id = id; @@ -213,9 +206,8 @@ impl Config { self.client.gateway_endpoint.gateway_listener.clone() } - #[cfg(not(feature = "coconut"))] - pub fn get_backup_bandwidth_token_keys_dir(&self) -> PathBuf { - self.client.backup_bandwidth_token_keys_dir.clone() + pub fn get_database_path(&self) -> PathBuf { + self.client.database_path.clone() } #[cfg(not(feature = "coconut"))] @@ -337,11 +329,8 @@ pub struct Client { /// Information regarding how the client should send data to gateway. gateway_endpoint: GatewayEndpoint, - /// Path to directory containing public/private keys used for bandwidth token purchase. - /// Those are saved in case of emergency, to be able to reclaim bandwidth tokens. - /// The public key is the name of the file, while the private key is the content. - #[cfg(not(feature = "coconut"))] - backup_bandwidth_token_keys_dir: PathBuf, + /// Path to the database containing bandwidth credentials of this client. + database_path: PathBuf, /// Ethereum private key. #[cfg(not(feature = "coconut"))] @@ -375,8 +364,7 @@ impl Default for Client { ack_key_file: Default::default(), reply_encryption_key_store_path: Default::default(), gateway_endpoint: Default::default(), - #[cfg(not(feature = "coconut"))] - backup_bandwidth_token_keys_dir: Default::default(), + database_path: Default::default(), #[cfg(not(feature = "coconut"))] eth_private_key: "".to_string(), #[cfg(not(feature = "coconut"))] @@ -415,10 +403,8 @@ impl Client { fn default_reply_encryption_key_store_path(id: &str) -> PathBuf { T::default_data_directory(Some(id)).join("reply_key_store") } - - #[cfg(not(feature = "coconut"))] - fn default_backup_bandwidth_token_keys_dir(id: &str) -> PathBuf { - T::default_data_directory(Some(id)).join("backup_bandwidth_token_keys") + fn default_database_path(id: &str) -> PathBuf { + T::default_data_directory(Some(id)).join("db.sqlite") } } diff --git a/clients/credential/Cargo.toml b/clients/credential/Cargo.toml index b0a4bc8b68..cfd7dc97f7 100644 --- a/clients/credential/Cargo.toml +++ b/clients/credential/Cargo.toml @@ -20,6 +20,7 @@ tokio = { version = "1.4", features = ["rt-multi-thread", "net", "signal", "macr coconut-bandwidth-contract-common = { path = "../../common/cosmwasm-smart-contracts/coconut-bandwidth-contract" } coconut-interface = { path = "../../common/coconut-interface" } credentials = { path = "../../common/credentials" } +credential-storage = { path = "../../common/credential-storage" } crypto = { path = "../../common/crypto", features = ["rand", "asymmetric", "symmetric", "aes", "hashing"] } network-defaults = { path = "../../common/network-defaults" } pemstore = { path = "../../common/pemstore" } diff --git a/clients/credential/src/commands.rs b/clients/credential/src/commands.rs index f61ff27fd3..62ab440fd2 100644 --- a/clients/credential/src/commands.rs +++ b/clients/credential/src/commands.rs @@ -9,6 +9,8 @@ use std::str::FromStr; use url::Url; use coconut_interface::{Attribute, Base58, BlindSignRequest, Bytable, Parameters}; +use credential_storage::storage::Storage; +use credential_storage::PersistentStorage; use credentials::coconut::bandwidth::{BandwidthVoucher, TOTAL_ATTRIBUTES}; use credentials::coconut::utils::obtain_aggregate_signature; use crypto::asymmetric::{encryption, identity}; @@ -32,7 +34,7 @@ pub(crate) enum Commands { #[async_trait] pub(crate) trait Execute { - async fn execute(&self, db: &mut PickleDb) -> Result<()>; + async fn execute(&self, db: &mut PickleDb, shared_storage: PersistentStorage) -> Result<()>; } #[derive(Args, Clone)] @@ -44,7 +46,7 @@ pub(crate) struct Deposit { #[async_trait] impl Execute for Deposit { - async fn execute(&self, db: &mut PickleDb) -> Result<()> { + async fn execute(&self, db: &mut PickleDb, _shared_storage: PersistentStorage) -> Result<()> { let mut rng = OsRng; let signing_keypair = KeyPair::from(identity::KeyPair::new(&mut rng)); let encryption_keypair = KeyPair::from(encryption::KeyPair::new(&mut rng)); @@ -80,7 +82,7 @@ pub(crate) struct ListDeposits {} #[async_trait] impl Execute for ListDeposits { - async fn execute(&self, db: &mut PickleDb) -> Result<()> { + async fn execute(&self, db: &mut PickleDb, _shared_storage: PersistentStorage) -> Result<()> { for kv in db.iter() { println!("{:?}", kv.get_value::()); } @@ -102,7 +104,7 @@ pub(crate) struct GetCredential { #[async_trait] impl Execute for GetCredential { - async fn execute(&self, db: &mut PickleDb) -> Result<()> { + async fn execute(&self, db: &mut PickleDb, shared_storage: PersistentStorage) -> Result<()> { let mut state = db .get::(&self.tx_hash) .ok_or(CredentialClientError::NoDeposit)?; @@ -162,6 +164,15 @@ impl Execute for GetCredential { let signature = obtain_aggregate_signature(¶ms, &bandwidth_credential_attributes, &urls).await?; + shared_storage + .insert_coconut_credential( + state.amount.to_string(), + VOUCHER_INFO.to_string(), + bandwidth_credential_attributes.get_private_attributes()[0].to_bs58(), + bandwidth_credential_attributes.get_private_attributes()[1].to_bs58(), + signature.to_bs58(), + ) + .await?; state.signature = Some(signature.to_bs58()); db.set(&self.tx_hash, &state).unwrap(); @@ -170,17 +181,3 @@ impl Execute for GetCredential { Ok(()) } } - -#[derive(Args, Clone)] -pub(crate) struct SpendCredential { - /// Spend one of the acquired credentials - #[clap(long)] - id: usize, -} - -#[async_trait] -impl Execute for SpendCredential { - async fn execute(&self, _db: &mut PickleDb) -> Result<()> { - Ok(()) - } -} diff --git a/clients/credential/src/error.rs b/clients/credential/src/error.rs index c3eb626bde..830e4d9e16 100644 --- a/clients/credential/src/error.rs +++ b/clients/credential/src/error.rs @@ -3,6 +3,7 @@ use thiserror::Error; +use credential_storage::error::StorageError; use credentials::error::Error as CredentialError; use crypto::asymmetric::encryption::KeyRecoveryError; use crypto::asymmetric::identity::Ed25519RecoveryError; @@ -38,4 +39,7 @@ pub enum CredentialClientError { #[error("Could not parse X25519 data")] X25519ParseError(#[from] KeyRecoveryError), + + #[error("Could not use shared storage")] + SharedStorageError(#[from] StorageError), } diff --git a/clients/credential/src/main.rs b/clients/credential/src/main.rs index be5b4c1522..6a34a71d8b 100644 --- a/clients/credential/src/main.rs +++ b/clients/credential/src/main.rs @@ -15,9 +15,9 @@ cfg_if::cfg_if! { use clap::Parser; use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod}; - pub const MNEMONIC: &str = "sun surge soon stomach flavor country gorilla dress oblige stamp attract hip soldier agree steel prize nuclear know enjoy arm bargain always theme matter"; + pub const MNEMONIC: &str = "jazz fatigue diagram account outer wrist slide cherry mother grid network pause wolf pig round answer mail junior better hair dismiss toward access end"; pub const NYMD_URL: &str = "http://127.0.0.1:26657"; - pub const CONTRACT_ADDRESS: &str = "nymt1vhjnzk9ly03dugffvzfcwgry4dgc8x0sscmfl2"; + pub const CONTRACT_ADDRESS: &str = "nymt1nc5tatafv6eyq7llkr2gv50ff9e22mnfp9pc5s"; pub const SIGNER_AUTHORITIES: [&str; 1] = [ "http://127.0.0.1:8080", ]; @@ -32,6 +32,8 @@ cfg_if::cfg_if! { #[tokio::main] async fn main() -> Result<()> { let args = Cli::parse(); + + let shared_storage = credential_storage::initialise_storage(std::path::PathBuf::from("/tmp/credential.db")).await; let mut db = match PickleDb::load( "credential.db", PickleDbDumpPolicy::AutoDump, @@ -46,9 +48,9 @@ cfg_if::cfg_if! { }; match &args.command { - Commands::Deposit(m) => m.execute(&mut db).await?, - Commands::ListDeposits(m) => m.execute(&mut db).await?, - Commands::GetCredential(m) => m.execute(&mut db).await?, + Commands::Deposit(m) => m.execute(&mut db, shared_storage).await?, + Commands::ListDeposits(m) => m.execute(&mut db, shared_storage).await?, + Commands::GetCredential(m) => m.execute(&mut db, shared_storage).await?, } Ok(()) diff --git a/clients/native/Cargo.toml b/clients/native/Cargo.toml index 1f11c15060..a0692eda1b 100644 --- a/clients/native/Cargo.toml +++ b/clients/native/Cargo.toml @@ -34,6 +34,7 @@ tokio-tungstenite = "0.14" # websocket client-core = { path = "../client-core" } coconut-interface = { path = "../../common/coconut-interface", optional = true } credentials = { path = "../../common/credentials", optional = true } +credential-storage = { path = "../../common/credential-storage" } config = { path = "../../common/config" } crypto = { path = "../../common/crypto" } gateway-client = { path = "../../common/client-libs/gateway-client" } @@ -42,12 +43,12 @@ nymsphinx = { path = "../../common/nymsphinx" } pemstore = { path = "../../common/pemstore" } topology = { path = "../../common/topology" } websocket-requests = { path = "websocket-requests" } -validator-client = { path = "../../common/client-libs/validator-client" } +validator-client = { path = "../../common/client-libs/validator-client", features = ["nymd-client"] } version-checker = { path = "../../common/version-checker" } network-defaults = { path = "../../common/network-defaults" } [features] -coconut = ["coconut-interface", "credentials", "credentials/coconut", "gateway-requests/coconut", "gateway-client/coconut"] +coconut = ["coconut-interface", "credentials", "credentials/coconut", "gateway-requests/coconut", "gateway-client/coconut", "client-core/coconut"] eth = [] [dev-dependencies] diff --git a/clients/native/src/client/config/template.rs b/clients/native/src/client/config/template.rs index afa215577c..8cb9140a27 100644 --- a/clients/native/src/client/config/template.rs +++ b/clients/native/src/client/config/template.rs @@ -46,10 +46,8 @@ public_encryption_key_file = '{{ client.public_encryption_key_file }}' # sent but not received back. reply_encryption_key_store_path = '{{ client.reply_encryption_key_store_path }}' -# Path to directory containing public/private keys used for bandwidth token purchase. -# Those are saved in case of emergency, to be able to reclaim bandwidth tokens. -# The public key is the name of the file, while the private key is the content. -backup_bandwidth_token_keys_dir = '{{ client.backup_bandwidth_token_keys_dir }}' +# Path to the database containing bandwidth credentials +database_path = '{{ client.database_path }}' # Ethereum private key. eth_private_key = '{{ client.eth_private_key }}' diff --git a/clients/native/src/client/mod.rs b/clients/native/src/client/mod.rs index d44339d324..c9899af37c 100644 --- a/clients/native/src/client/mod.rs +++ b/clients/native/src/client/mod.rs @@ -174,14 +174,16 @@ impl NymClient { #[cfg(feature = "coconut")] let bandwidth_controller = BandwidthController::new( + credential_storage::initialise_storage(self.config.get_base().get_database_path()) + .await, self.config.get_base().get_validator_api_endpoints(), - *self.key_manager.identity_keypair().public_key(), ); #[cfg(not(feature = "coconut"))] let bandwidth_controller = BandwidthController::new( + credential_storage::initialise_storage(self.config.get_base().get_database_path()) + .await, self.config.get_base().get_eth_endpoint(), self.config.get_base().get_eth_private_key(), - self.config.get_base().get_backup_bandwidth_token_keys_dir(), ) .expect("Could not create bandwidth controller"); diff --git a/clients/native/src/commands/init.rs b/clients/native/src/commands/init.rs index f0de4a445d..b2cfd95a4d 100644 --- a/clients/native/src/commands/init.rs +++ b/clients/native/src/commands/init.rs @@ -4,21 +4,10 @@ use clap::{App, Arg, ArgMatches}; use client_core::client::key_manager::KeyManager; use client_core::config::persistence::key_pathfinder::ClientKeyPathfinder; -#[cfg(feature = "coconut")] -use coconut_interface::{Credential, Parameters}; use config::NymConfig; -#[cfg(feature = "coconut")] -use credentials::coconut::{ - bandwidth::prepare_for_spending, bandwidth::BandwidthVoucher, bandwidth::TOTAL_ATTRIBUTES, - utils::obtain_aggregate_signature, -}; -#[cfg(feature = "coconut")] -use credentials::obtain_aggregate_verification_key; use crypto::asymmetric::{encryption, identity}; use gateway_client::GatewayClient; use gateway_requests::registration::handshake::SharedKeys; -#[cfg(feature = "coconut")] -use network_defaults::{BANDWIDTH_VALUE, VOUCHER_INFO}; use nymsphinx::addressing::clients::Recipient; use nymsphinx::addressing::nodes::NodeIdentity; use rand::rngs::OsRng; @@ -29,8 +18,6 @@ use std::sync::Arc; use std::time::Duration; use topology::{filter::VersionFilterable, gateway}; use url::Url; -#[cfg(feature = "coconut")] -use validator_client::nymd::tx::Hash; use crate::client::config::Config; use crate::commands::override_config; @@ -101,46 +88,6 @@ pub fn command_args<'a, 'b>() -> clap::App<'a, 'b> { app } -// this behaviour should definitely be changed, we shouldn't -// need to get bandwidth credential for registration -#[cfg(feature = "coconut")] -async fn _prepare_temporary_credential(validators: &[Url], raw_identity: &[u8]) -> Credential { - let verification_key = obtain_aggregate_verification_key(validators) - .await - .expect("could not obtain aggregate verification key of validators"); - - let params = Parameters::new(TOTAL_ATTRIBUTES).unwrap(); - let mut rng = OsRng; - let bandwidth_credential_attributes = BandwidthVoucher::new( - ¶ms, - BANDWIDTH_VALUE.to_string(), - VOUCHER_INFO.to_string(), - Hash::new([0; 32]), - // workaround for putting a valid value here, without deriving clone for the private - // key, until we have actual useful values - identity::PrivateKey::from_base58_string( - identity::KeyPair::new(&mut rng) - .private_key() - .to_base58_string(), - ) - .unwrap(), - encryption::KeyPair::new(&mut rng).private_key().clone(), - ); - - let bandwidth_credential = - obtain_aggregate_signature(¶ms, &bandwidth_credential_attributes, validators) - .await - .expect("could not obtain bandwidth credential"); - - prepare_for_spending( - raw_identity, - &bandwidth_credential, - &bandwidth_credential_attributes, - &verification_key, - ) - .expect("could not prepare out bandwidth credential for spending") -} - async fn register_with_gateway( gateway: &gateway::Node, our_identity: Arc, diff --git a/clients/socks5/Cargo.toml b/clients/socks5/Cargo.toml index a607ee4930..24771d1b6a 100644 --- a/clients/socks5/Cargo.toml +++ b/clients/socks5/Cargo.toml @@ -27,6 +27,7 @@ url = "2.2" client-core = { path = "../client-core" } coconut-interface = { path = "../../common/coconut-interface", optional = true } credentials = { path = "../../common/credentials", optional = true } +credential-storage = { path = "../../common/credential-storage" } config = { path = "../../common/config" } crypto = { path = "../../common/crypto" } gateway-client = { path = "../../common/client-libs/gateway-client" } @@ -37,12 +38,12 @@ socks5-requests = { path = "../../common/socks5/requests" } topology = { path = "../../common/topology" } pemstore = { path = "../../common/pemstore" } proxy-helpers = { path = "../../common/socks5/proxy-helpers" } -validator-client = { path = "../../common/client-libs/validator-client" } +validator-client = { path = "../../common/client-libs/validator-client", features = ["nymd-client"] } version-checker = { path = "../../common/version-checker" } network-defaults = { path = "../../common/network-defaults" } [features] -coconut = ["coconut-interface", "credentials", "gateway-requests/coconut", "gateway-client/coconut", "credentials/coconut"] +coconut = ["coconut-interface", "credentials", "gateway-requests/coconut", "gateway-client/coconut", "credentials/coconut", "client-core/coconut"] eth = [] [build-dependencies] diff --git a/clients/socks5/src/client/config/template.rs b/clients/socks5/src/client/config/template.rs index 590fcc47c5..321242825d 100644 --- a/clients/socks5/src/client/config/template.rs +++ b/clients/socks5/src/client/config/template.rs @@ -46,10 +46,8 @@ public_encryption_key_file = '{{ client.public_encryption_key_file }}' # sent but not received back. reply_encryption_key_store_path = '{{ client.reply_encryption_key_store_path }}' -# Path to directory containing public/private keys used for bandwidth token purchase. -# Those are saved in case of emergency, to be able to reclaim bandwidth tokens. -# The public key is the name of the file, while the private key is the content. -backup_bandwidth_token_keys_dir = '{{ client.backup_bandwidth_token_keys_dir }}' +# Path to the database containing bandwidth credentials +database_path = '{{ client.database_path }}' # Ethereum private key. eth_private_key = '{{ client.eth_private_key }}' diff --git a/clients/socks5/src/client/mod.rs b/clients/socks5/src/client/mod.rs index 00cad6e0d0..15cb284f65 100644 --- a/clients/socks5/src/client/mod.rs +++ b/clients/socks5/src/client/mod.rs @@ -162,14 +162,16 @@ impl NymClient { #[cfg(feature = "coconut")] let bandwidth_controller = BandwidthController::new( + credential_storage::initialise_storage(self.config.get_base().get_database_path()) + .await, self.config.get_base().get_validator_api_endpoints(), - *self.key_manager.identity_keypair().public_key(), ); #[cfg(not(feature = "coconut"))] let bandwidth_controller = BandwidthController::new( + credential_storage::initialise_storage(self.config.get_base().get_database_path()) + .await, self.config.get_base().get_eth_endpoint(), self.config.get_base().get_eth_private_key(), - self.config.get_base().get_backup_bandwidth_token_keys_dir(), ) .expect("Could not create bandwidth controller"); diff --git a/clients/socks5/src/commands/init.rs b/clients/socks5/src/commands/init.rs index 9751365a4f..911438c3d3 100644 --- a/clients/socks5/src/commands/init.rs +++ b/clients/socks5/src/commands/init.rs @@ -4,21 +4,10 @@ use clap::{App, Arg, ArgMatches}; use client_core::client::key_manager::KeyManager; use client_core::config::persistence::key_pathfinder::ClientKeyPathfinder; -#[cfg(feature = "coconut")] -use coconut_interface::{Credential, Parameters}; use config::NymConfig; -#[cfg(feature = "coconut")] -use credentials::coconut::{ - bandwidth::prepare_for_spending, bandwidth::BandwidthVoucher, bandwidth::TOTAL_ATTRIBUTES, - utils::obtain_aggregate_signature, -}; -#[cfg(feature = "coconut")] -use credentials::obtain_aggregate_verification_key; use crypto::asymmetric::{encryption, identity}; use gateway_client::GatewayClient; use gateway_requests::registration::handshake::SharedKeys; -#[cfg(feature = "coconut")] -use network_defaults::BANDWIDTH_VALUE; use nymsphinx::addressing::clients::Recipient; use nymsphinx::addressing::nodes::NodeIdentity; use rand::{prelude::SliceRandom, rngs::OsRng, thread_rng}; @@ -27,8 +16,6 @@ use std::sync::Arc; use std::time::Duration; use topology::{filter::VersionFilterable, gateway}; use url::Url; -#[cfg(feature = "coconut")] -use validator_client::nymd::tx::Hash; use crate::client::config::Config; use crate::commands::override_config; @@ -101,46 +88,6 @@ pub fn command_args<'a, 'b>() -> clap::App<'a, 'b> { app } -// this behaviour should definitely be changed, we shouldn't -// need to get bandwidth credential for registration -#[cfg(feature = "coconut")] -async fn _prepare_temporary_credential(validators: &[Url], raw_identity: &[u8]) -> Credential { - let verification_key = obtain_aggregate_verification_key(validators) - .await - .expect("could not obtain aggregate verification key of validators"); - - let params = Parameters::new(TOTAL_ATTRIBUTES).unwrap(); - let mut rng = OsRng; - let bandwidth_credential_attributes = BandwidthVoucher::new( - ¶ms, - BANDWIDTH_VALUE.to_string(), - network_defaults::VOUCHER_INFO.to_string(), - Hash::new([0; 32]), - // workaround for putting a valid value here, without deriving clone for the private - // key, until we have actual useful values - identity::PrivateKey::from_base58_string( - identity::KeyPair::new(&mut rng) - .private_key() - .to_base58_string(), - ) - .unwrap(), - encryption::KeyPair::new(&mut rng).private_key().clone(), - ); - - let bandwidth_credential = - obtain_aggregate_signature(¶ms, &bandwidth_credential_attributes, validators) - .await - .expect("could not obtain bandwidth credential"); - - prepare_for_spending( - raw_identity, - &bandwidth_credential, - &bandwidth_credential_attributes, - &verification_key, - ) - .expect("could not prepare out bandwidth credential for spending") -} - async fn register_with_gateway( gateway: &gateway::Node, our_identity: Arc, diff --git a/clients/webassembly/src/client/mod.rs b/clients/webassembly/src/client/mod.rs index eba1a21249..499de7dcf3 100644 --- a/clients/webassembly/src/client/mod.rs +++ b/clients/webassembly/src/client/mod.rs @@ -109,12 +109,6 @@ impl NymClient { pub async fn initial_setup(self) -> Self { let testnet_mode = self.testnet_mode; - #[cfg(feature = "coconut")] - let bandwidth_controller = Some(gateway_client::bandwidth::BandwidthController::new( - vec![self.validator_server.clone()], - *self.identity.public_key(), - )); - #[cfg(not(feature = "coconut"))] let bandwidth_controller = None; let mut client = self.get_and_update_topology().await; diff --git a/common/client-libs/gateway-client/Cargo.toml b/common/client-libs/gateway-client/Cargo.toml index 72f5ed8b4c..004619e4d3 100644 --- a/common/client-libs/gateway-client/Cargo.toml +++ b/common/client-libs/gateway-client/Cargo.toml @@ -17,9 +17,9 @@ url = "2.2" rand = { version = "0.7.3", features = ["wasm-bindgen"] } secp256k1 = "0.20.3" web3 = { version = "0.17.0", default-features = false } +async-trait = { version = "0.1.51" } # internal -cosmrs = { version = "0.4.1", optional = true } credentials = { path = "../../credentials" } crypto = { path = "../../crypto" } gateway-requests = { path = "../../../gateway/gateway-requests" } @@ -41,6 +41,9 @@ features = ["macros", "rt", "net", "sync", "time"] [target."cfg(not(target_arch = \"wasm32\"))".dependencies.tokio-tungstenite] version = "0.14" +[target."cfg(not(target_arch = \"wasm32\"))".dependencies.credential-storage] +path = "../../credential-storage" + # wasm-only dependencies [target."cfg(target_arch = \"wasm32\")".dependencies.wasm-bindgen] version = "0.2" @@ -69,6 +72,6 @@ features = ["js"] #url = "2.1" [features] -coconut = ["gateway-requests/coconut", "coconut-interface", "validator-client", "credentials/coconut", "cosmrs"] +coconut = ["gateway-requests/coconut", "coconut-interface", "validator-client", "credentials/coconut"] wasm = ["web3/wasm", "web3/http", "web3/signing"] default = ["web3/default"] \ No newline at end of file diff --git a/common/client-libs/gateway-client/src/bandwidth.rs b/common/client-libs/gateway-client/src/bandwidth.rs index 2743bca707..bd070c286c 100644 --- a/common/client-libs/gateway-client/src/bandwidth.rs +++ b/common/client-libs/gateway-client/src/bandwidth.rs @@ -1,33 +1,35 @@ // Copyright 2021 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +#[cfg(target_arch = "wasm32")] +use crate::wasm_storage::{Storage, StorageError}; #[cfg(feature = "coconut")] -use cosmrs::tx::Hash; +use coconut_interface::Base58; +#[cfg(feature = "coconut")] +#[cfg(not(target_arch = "wasm32"))] +use credential_storage::error::StorageError; +#[cfg(not(target_arch = "wasm32"))] +use credential_storage::storage::Storage; #[cfg(feature = "coconut")] use credentials::coconut::{ - bandwidth::{prepare_for_spending, BandwidthVoucher, TOTAL_ATTRIBUTES}, - utils::{obtain_aggregate_signature, obtain_aggregate_verification_key}, + bandwidth::prepare_for_spending, utils::obtain_aggregate_verification_key, }; #[cfg(not(feature = "coconut"))] use credentials::token::bandwidth::TokenCredential; -#[cfg(feature = "coconut")] -use crypto::asymmetric::encryption; +#[cfg(not(feature = "coconut"))] use crypto::asymmetric::identity; -use network_defaults::BANDWIDTH_VALUE; #[cfg(not(feature = "coconut"))] use network_defaults::{ - eth_contract::ETH_ERC20_JSON_ABI, eth_contract::ETH_JSON_ABI, ETH_BURN_FUNCTION_NAME, - ETH_CONTRACT_ADDRESS, ETH_ERC20_APPROVE_FUNCTION_NAME, ETH_ERC20_CONTRACT_ADDRESS, - ETH_MIN_BLOCK_DEPTH, TOKENS_TO_BURN, UTOKENS_TO_BURN, + eth_contract::ETH_ERC20_JSON_ABI, eth_contract::ETH_JSON_ABI, BANDWIDTH_VALUE, + ETH_BURN_FUNCTION_NAME, ETH_CONTRACT_ADDRESS, ETH_ERC20_APPROVE_FUNCTION_NAME, + ETH_ERC20_CONTRACT_ADDRESS, ETH_MIN_BLOCK_DEPTH, TOKENS_TO_BURN, UTOKENS_TO_BURN, }; #[cfg(not(feature = "coconut"))] use pemstore::traits::PemStorableKeyPair; +#[cfg(not(feature = "coconut"))] use rand::rngs::OsRng; #[cfg(not(feature = "coconut"))] use secp256k1::SecretKey; -#[cfg(not(feature = "coconut"))] -use std::io::{Read, Write}; -#[cfg(not(feature = "coconut"))] use std::str::FromStr; #[cfg(not(feature = "coconut"))] use web3::{ @@ -68,35 +70,35 @@ pub fn eth_erc20_contract(web3: Web3) -> Contract { } #[derive(Clone)] -pub struct BandwidthController { +pub struct BandwidthController { + storage: St, #[cfg(feature = "coconut")] validator_endpoints: Vec, - #[cfg(feature = "coconut")] - identity: identity::PublicKey, #[cfg(not(feature = "coconut"))] contract: Contract, #[cfg(not(feature = "coconut"))] erc20_contract: Contract, #[cfg(not(feature = "coconut"))] eth_private_key: SecretKey, - #[cfg(not(feature = "coconut"))] - backup_bandwidth_token_keys_dir: std::path::PathBuf, } -impl BandwidthController { +impl BandwidthController +where + St: Storage + Clone + 'static, +{ #[cfg(feature = "coconut")] - pub fn new(validator_endpoints: Vec, identity: identity::PublicKey) -> Self { + pub fn new(storage: St, validator_endpoints: Vec) -> Self { BandwidthController { + storage, validator_endpoints, - identity, } } #[cfg(not(feature = "coconut"))] pub fn new( + storage: St, eth_endpoint: String, eth_private_key: String, - backup_bandwidth_token_keys_dir: std::path::PathBuf, ) -> Result { // Fail early, on invalid url let transport = @@ -109,60 +111,42 @@ impl BandwidthController { .map_err(|_| GatewayClientError::InvalidEthereumPrivateKey)?; Ok(BandwidthController { + storage, contract, erc20_contract, eth_private_key, - backup_bandwidth_token_keys_dir, }) } #[cfg(not(feature = "coconut"))] - fn backup_keypair(&self, keypair: &identity::KeyPair) -> Result<(), GatewayClientError> { - std::fs::create_dir_all(&self.backup_bandwidth_token_keys_dir)?; - let file_path = self - .backup_bandwidth_token_keys_dir - .join(keypair.public_key().to_base58_string()); - let mut file = std::fs::File::create(file_path)?; - file.write_all(&keypair.private_key().to_bytes())?; + async fn backup_keypair(&self, keypair: &identity::KeyPair) -> Result<(), GatewayClientError> { + self.storage + .insert_erc20_credential( + keypair.public_key().to_base58_string(), + keypair.private_key().to_base58_string(), + ) + .await?; Ok(()) } #[cfg(not(feature = "coconut"))] - fn restore_keypair(&self) -> Result { - std::fs::create_dir_all(&self.backup_bandwidth_token_keys_dir)?; - let file = std::fs::read_dir(&self.backup_bandwidth_token_keys_dir)? - .find(|entry| { - entry - .as_ref() - .map(|entry| entry.path().is_file()) - .unwrap_or(false) - }) - .unwrap_or_else(|| Err(std::io::Error::from(std::io::ErrorKind::NotFound)))?; - let file_path = file.path(); - let pub_key = file_path - .file_name() - .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))? - .to_str() - .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))?; - let mut priv_key = vec![]; - std::fs::File::open(file_path.clone())?.read_to_end(&mut priv_key)?; - Ok(identity::KeyPair::from_keys( - identity::PrivateKey::from_bytes(&priv_key).unwrap(), - identity::PublicKey::from_base58_string(pub_key).unwrap(), - )) + async fn restore_keypair(&self) -> Result { + let data = self.storage.get_next_erc20_credential().await?; + let public_key = identity::PublicKey::from_base58_string(data.public_key).unwrap(); + let private_key = identity::PrivateKey::from_base58_string(data.private_key).unwrap(); + + Ok(identity::KeyPair::from_keys(private_key, public_key)) } #[cfg(not(feature = "coconut"))] - fn mark_keypair_as_spent(&self, keypair: &identity::KeyPair) -> Result<(), GatewayClientError> { - let mut spent_dir = self.backup_bandwidth_token_keys_dir.clone(); - spent_dir.push("spent"); - std::fs::create_dir_all(&spent_dir)?; - let file_path_old = self - .backup_bandwidth_token_keys_dir - .join(keypair.public_key().to_base58_string()); - let file_path_new = spent_dir.join(keypair.public_key().to_base58_string()); - std::fs::rename(file_path_old, file_path_new)?; + async fn mark_keypair_as_spent( + &self, + keypair: &identity::KeyPair, + ) -> Result<(), GatewayClientError> { + self.storage + .consume_erc20_credential(keypair.public_key().to_base58_string()) + .await?; Ok(()) } @@ -172,39 +156,24 @@ impl BandwidthController { &self, ) -> Result { let verification_key = obtain_aggregate_verification_key(&self.validator_endpoints).await?; - let params = coconut_interface::Parameters::new(TOTAL_ATTRIBUTES).unwrap(); - - let mut rng = OsRng; - // TODO: Decide what is the value and additional info associated with the bandwidth voucher - let bandwidth_credential_attributes = BandwidthVoucher::new( - ¶ms, - BANDWIDTH_VALUE.to_string(), - network_defaults::VOUCHER_INFO.to_string(), - Hash::new([0; 32]), - // workaround for putting a valid value here, without deriving clone for the private - // key, until we have actual useful values - identity::PrivateKey::from_base58_string( - identity::KeyPair::new(&mut rng) - .private_key() - .to_base58_string(), - ) - .unwrap(), - encryption::KeyPair::new(&mut rng).private_key().clone(), - ); - - let bandwidth_credential = obtain_aggregate_signature( - ¶ms, - &bandwidth_credential_attributes, - &self.validator_endpoints, - ) - .await?; - // the above would presumably be loaded from a file + let bandwidth_credential = self.storage.get_next_coconut_credential().await?; + let voucher_value = u64::from_str(&bandwidth_credential.voucher_value) + .map_err(|_| StorageError::InconsistentData)?; + let voucher_info = bandwidth_credential.voucher_info.clone(); + let serial_number = + coconut_interface::Attribute::try_from_bs58(bandwidth_credential.serial_number)?; + let binding_number = + coconut_interface::Attribute::try_from_bs58(bandwidth_credential.binding_number)?; + let signature = + coconut_interface::Signature::try_from_bs58(bandwidth_credential.signature)?; // the below would only be executed once we know where we want to spend it (i.e. which gateway and stuff) Ok(prepare_for_spending( - &self.identity.to_bytes(), - &bandwidth_credential, - &bandwidth_credential_attributes, + voucher_value, + voucher_info, + serial_number, + binding_number, + &signature, &verification_key, )?) } @@ -215,12 +184,12 @@ impl BandwidthController { gateway_identity: identity::PublicKey, gateway_owner: String, ) -> Result { - let kp = match self.restore_keypair() { + let kp = match self.restore_keypair().await { Ok(kp) => kp, Err(_) => { let mut rng = OsRng; let kp = identity::KeyPair::new(&mut rng); - self.backup_keypair(&kp)?; + self.backup_keypair(&kp).await?; kp } }; @@ -230,7 +199,7 @@ impl BandwidthController { self.buy_token_credential(verification_key, signed_verification_key, gateway_owner) .await?; - self.mark_keypair_as_spent(&kp)?; + self.mark_keypair_as_spent(&kp).await?; let message: Vec = verification_key .to_bytes() diff --git a/common/client-libs/gateway-client/src/client.rs b/common/client-libs/gateway-client/src/client.rs index 074a42d741..848ca3fe62 100644 --- a/common/client-libs/gateway-client/src/client.rs +++ b/common/client-libs/gateway-client/src/client.rs @@ -9,8 +9,12 @@ pub use crate::packet_router::{ AcknowledgementReceiver, AcknowledgementSender, MixnetMessageReceiver, MixnetMessageSender, }; use crate::socket_state::{PartiallyDelegated, SocketState}; +#[cfg(target_arch = "wasm32")] +use crate::wasm_storage::PersistentStorage; #[cfg(feature = "coconut")] use coconut_interface::Credential; +#[cfg(not(target_arch = "wasm32"))] +use credential_storage::PersistentStorage; #[cfg(not(feature = "coconut"))] use credentials::token::bandwidth::TokenCredential; use crypto::asymmetric::identity; @@ -51,7 +55,7 @@ pub struct GatewayClient { connection: SocketState, packet_router: PacketRouter, response_timeout_duration: Duration, - bandwidth_controller: Option, + bandwidth_controller: Option>, // reconnection related variables /// Specifies whether client should try to reconnect to gateway on connection failure. @@ -75,7 +79,7 @@ impl GatewayClient { mixnet_message_sender: MixnetMessageSender, ack_sender: AcknowledgementSender, response_timeout_duration: Duration, - bandwidth_controller: Option, + bandwidth_controller: Option>, ) -> Self { GatewayClient { authenticated: false, diff --git a/common/client-libs/gateway-client/src/error.rs b/common/client-libs/gateway-client/src/error.rs index c45e27002a..0890dcd12a 100644 --- a/common/client-libs/gateway-client/src/error.rs +++ b/common/client-libs/gateway-client/src/error.rs @@ -1,6 +1,10 @@ // Copyright 2021 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +#[cfg(target_arch = "wasm32")] +use crate::wasm_storage::StorageError; +#[cfg(not(target_arch = "wasm32"))] +use credential_storage::error::StorageError; use gateway_requests::registration::handshake::error::HandshakeError; use std::io; use thiserror::Error; @@ -21,15 +25,18 @@ pub enum GatewayClientError { #[error("There was a network error - {0}")] NetworkError(#[from] WsError), + #[error("There was a credential storage error - {0}")] + CredentialStorageError(#[from] StorageError), + + #[cfg(feature = "coconut")] + #[error("Coconut error - {0}")] + CoconutError(#[from] coconut_interface::CoconutError), + // TODO: see if `JsValue` is a reasonable type for this #[cfg(target_arch = "wasm32")] #[error("There was a network error")] NetworkErrorWasm(JsValue), - #[cfg(not(feature = "coconut"))] - #[error("Keypair IO error - {0}")] - IOError(#[from] std::io::Error), - #[cfg(not(feature = "coconut"))] #[error("Could not burn ERC20 token in Ethereum smart contract - {0}")] BurnTokenError(#[from] Web3Error), @@ -69,6 +76,9 @@ pub enum GatewayClientError { #[error("Client does not have enough bandwidth: estimated {0}, remaining: {1}")] NotEnoughBandwidth(i64, i64), + #[error("There are no more bandwidth credentials acquired. Please buy some more if you want to use the mixnet")] + NoMoreBandwidthCredentials, + #[error("Received an unexpected response")] UnexpectedResponse, diff --git a/common/client-libs/gateway-client/src/lib.rs b/common/client-libs/gateway-client/src/lib.rs index dc7b17d8b4..81e682c019 100644 --- a/common/client-libs/gateway-client/src/lib.rs +++ b/common/client-libs/gateway-client/src/lib.rs @@ -13,6 +13,8 @@ pub mod client; pub mod error; pub mod packet_router; pub mod socket_state; +#[cfg(feature = "wasm")] +mod wasm_storage; /// Helper method for reading from websocket stream. Helps to flatten the structure. pub(crate) fn cleanup_socket_message( diff --git a/common/client-libs/gateway-client/src/wasm_storage.rs b/common/client-libs/gateway-client/src/wasm_storage.rs new file mode 100644 index 0000000000..5948762ca9 --- /dev/null +++ b/common/client-libs/gateway-client/src/wasm_storage.rs @@ -0,0 +1,98 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum StorageError { + #[error("Wasm client is not yet supported")] + WasmNotSupported, + + #[allow(dead_code)] + #[error("Code shouldn't reach this point")] + InconsistentData, +} + +#[derive(Clone)] +pub struct PersistentStorage {} + +pub struct CoconutCredential { + pub id: i64, + pub voucher_value: String, + pub voucher_info: String, + pub serial_number: String, + pub binding_number: String, + pub signature: String, +} + +pub struct ERC20Credential { + pub id: i64, + pub public_key: String, + pub private_key: String, + pub consumed: bool, +} + +#[async_trait] +pub trait Storage: Send + Sync { + async fn insert_coconut_credential( + &self, + voucher_value: String, + voucher_info: String, + serial_number: String, + binding_number: String, + signature: String, + ) -> Result<(), StorageError>; + + async fn get_next_coconut_credential(&self) -> Result; + + async fn remove_coconut_credential(&self, id: i64) -> Result<(), StorageError>; + + async fn insert_erc20_credential( + &self, + public_key: String, + private_key: String, + ) -> Result<(), StorageError>; + + async fn get_next_erc20_credential(&self) -> Result; + + async fn consume_erc20_credential(&self, public_key: String) -> Result<(), StorageError>; +} + +#[async_trait] +impl Storage for PersistentStorage { + async fn insert_coconut_credential( + &self, + _voucher_value: String, + _voucher_info: String, + _serial_number: String, + _binding_number: String, + _signature: String, + ) -> Result<(), StorageError> { + Err(StorageError::WasmNotSupported) + } + + async fn get_next_coconut_credential(&self) -> Result { + Err(StorageError::WasmNotSupported) + } + + async fn remove_coconut_credential(&self, _id: i64) -> Result<(), StorageError> { + Err(StorageError::WasmNotSupported) + } + + async fn insert_erc20_credential( + &self, + _public_key: String, + _private_key: String, + ) -> Result<(), StorageError> { + Err(StorageError::WasmNotSupported) + } + + async fn get_next_erc20_credential(&self) -> Result { + Err(StorageError::WasmNotSupported) + } + + async fn consume_erc20_credential(&self, _public_key: String) -> Result<(), StorageError> { + Err(StorageError::WasmNotSupported) + } +} diff --git a/common/coconut-interface/src/error.rs b/common/coconut-interface/src/error.rs index ee1d42ade5..95ba31db88 100644 --- a/common/coconut-interface/src/error.rs +++ b/common/coconut-interface/src/error.rs @@ -10,4 +10,10 @@ pub enum CoconutInterfaceError { #[error("Could not decode base 58 string - {0}")] MalformedString(#[from] bs58::decode::Error), + + #[error("Not enough public attributes were specified")] + NotEnoughPublicAttributes, + + #[error("Could not recover bandwidth value")] + InvalidBandwidth, } diff --git a/common/coconut-interface/src/lib.rs b/common/coconut-interface/src/lib.rs index 1a6c6216f1..1e8d726f48 100644 --- a/common/coconut-interface/src/lib.rs +++ b/common/coconut-interface/src/lib.rs @@ -5,6 +5,7 @@ pub mod error; use getset::{CopyGetters, Getters}; use serde::{Deserialize, Serialize}; +use std::str::FromStr; use error::CoconutInterfaceError; @@ -24,9 +25,11 @@ impl Credential { pub fn new( n_params: u32, theta: Theta, - public_attributes: Vec>, + voucher_value: String, + voucher_info: String, signature: &Signature, ) -> Credential { + let public_attributes = vec![voucher_value.into_bytes(), voucher_info.into_bytes()]; Credential { n_params, theta, @@ -35,8 +38,18 @@ impl Credential { } } - pub fn public_attributes(&self) -> Vec> { - self.public_attributes.clone() + pub fn voucher_value(&self) -> Result { + let bandwidth_vec = self + .public_attributes + .get(0) + .ok_or(CoconutInterfaceError::NotEnoughPublicAttributes)? + .to_owned(); + let bandwidth_str = String::from_utf8(bandwidth_vec) + .map_err(|_| CoconutInterfaceError::InvalidBandwidth)?; + let value = + u64::from_str(&bandwidth_str).map_err(|_| CoconutInterfaceError::InvalidBandwidth)?; + + Ok(value) } pub fn verify(&self, verification_key: &VerificationKey) -> bool { diff --git a/common/config/src/lib.rs b/common/config/src/lib.rs index 61e95e9ca9..d19c834be0 100644 --- a/common/config/src/lib.rs +++ b/common/config/src/lib.rs @@ -4,6 +4,8 @@ use handlebars::Handlebars; use serde::de::DeserializeOwned; use serde::Serialize; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::{fs, io}; @@ -64,11 +66,19 @@ pub trait NymConfig: Default + Serialize + DeserializeOwned { None => fs::create_dir_all(self.config_directory()), }?; - fs::write( - custom_location - .unwrap_or_else(|| self.config_directory().join(Self::config_file_name())), - templated_config, - ) + let location = custom_location + .unwrap_or_else(|| self.config_directory().join(Self::config_file_name())); + + fs::write(location.clone(), templated_config)?; + + #[cfg(unix)] + let mut perms = fs::metadata(location.clone())?.permissions(); + #[cfg(unix)] + perms.set_mode(0o600); + #[cfg(unix)] + fs::set_permissions(location, perms)?; + + Ok(()) } fn load_from_file(id: Option<&str>) -> io::Result { diff --git a/common/cosmwasm-smart-contracts/coconut-bandwidth-contract/Cargo.toml b/common/cosmwasm-smart-contracts/coconut-bandwidth-contract/Cargo.toml index 22803dc18d..5baf73bff6 100644 --- a/common/cosmwasm-smart-contracts/coconut-bandwidth-contract/Cargo.toml +++ b/common/cosmwasm-smart-contracts/coconut-bandwidth-contract/Cargo.toml @@ -6,5 +6,6 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +cosmwasm-std = "1.0.0-beta6" schemars = "0.8" serde = { version = "1.0.103", default-features = false, features = ["derive"] } diff --git a/common/cosmwasm-smart-contracts/coconut-bandwidth-contract/src/msg.rs b/common/cosmwasm-smart-contracts/coconut-bandwidth-contract/src/msg.rs index 54679992e7..8045b1247d 100644 --- a/common/cosmwasm-smart-contracts/coconut-bandwidth-contract/src/msg.rs +++ b/common/cosmwasm-smart-contracts/coconut-bandwidth-contract/src/msg.rs @@ -1,18 +1,23 @@ // Copyright 2021 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use cosmwasm_std::Coin; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::deposit::DepositData; #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] -pub struct InstantiateMsg {} +pub struct InstantiateMsg { + pub multisig_addr: String, + pub pool_addr: String, +} #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ExecuteMsg { DepositFunds { data: DepositData }, + ReleaseFunds { funds: Coin }, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] diff --git a/common/credential-storage/Cargo.toml b/common/credential-storage/Cargo.toml new file mode 100644 index 0000000000..fe05fb4bd3 --- /dev/null +++ b/common/credential-storage/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "credential-storage" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +async-trait = { version = "0.1.51" } +nymcoconut = { path = "../nymcoconut" } + +log = "0.4" +sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"]} +thiserror = "1.0" +tokio = { version = "1.4", features = [ "rt-multi-thread", "net", "signal", "fs" ] } + + +[build-dependencies] +sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "sqlite", "macros", "migrate"] } +tokio = { version = "1.4", features = ["rt-multi-thread", "macros"] } \ No newline at end of file diff --git a/common/credential-storage/build.rs b/common/credential-storage/build.rs new file mode 100644 index 0000000000..0640039329 --- /dev/null +++ b/common/credential-storage/build.rs @@ -0,0 +1,30 @@ +/* + * Copyright 2022 - Nym Technologies SA + * SPDX-License-Identifier: Apache-2.0 + */ + +use sqlx::{Connection, SqliteConnection}; +use std::env; + +#[tokio::main] +async fn main() { + let out_dir = env::var("OUT_DIR").unwrap(); + let database_path = format!("{}/coconut-credential-example.sqlite", out_dir); + + let mut conn = SqliteConnection::connect(&*format!("sqlite://{}?mode=rwc", database_path)) + .await + .expect("Failed to create SQLx database connection"); + + sqlx::migrate!("./migrations") + .run(&mut conn) + .await + .expect("Failed to perform SQLx migrations"); + + #[cfg(target_family = "unix")] + println!("cargo:rustc-env=DATABASE_URL=sqlite://{}", &database_path); + + #[cfg(target_family = "windows")] + // for some strange reason we need to add a leading `/` to the windows path even though it's + // not a valid windows path... but hey, it works... + println!("cargo:rustc-env=DATABASE_URL=sqlite:///{}", &database_path); +} diff --git a/common/credential-storage/migrations/20220408120000_initial_tables.sql b/common/credential-storage/migrations/20220408120000_initial_tables.sql new file mode 100644 index 0000000000..09aa9c8739 --- /dev/null +++ b/common/credential-storage/migrations/20220408120000_initial_tables.sql @@ -0,0 +1,22 @@ +/* + * Copyright 2022 - Nym Technologies SA + * SPDX-License-Identifier: Apache-2.0 + */ + +CREATE TABLE coconut_credentials +( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + voucher_value TEXT NOT NULL, + voucher_info TEXT NOT NULL, + serial_number TEXT NOT NULL, + binding_number TEXT NOT NULL, + signature TEXT NOT NULL UNIQUE +); + +CREATE TABLE erc20_credentials +( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + private_key TEXT NOT NULL, + consumed BOOLEAN NOT NULL +); \ No newline at end of file diff --git a/common/credential-storage/src/coconut.rs b/common/credential-storage/src/coconut.rs new file mode 100644 index 0000000000..e6512dd7fc --- /dev/null +++ b/common/credential-storage/src/coconut.rs @@ -0,0 +1,67 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::models::CoconutCredential; + +#[derive(Clone)] +pub(crate) struct CoconutCredentialManager { + connection_pool: sqlx::SqlitePool, +} + +impl CoconutCredentialManager { + /// Creates new instance of the `CoconutCredentialManager` with the provided sqlite connection pool. + /// + /// # Arguments + /// + /// * `connection_pool`: database connection pool to use. + pub(crate) fn new(connection_pool: sqlx::SqlitePool) -> Self { + CoconutCredentialManager { connection_pool } + } + + /// Inserts provided signature into the database. + /// + /// # Arguments + /// + /// * `voucher_value`: Plaintext bandwidth value of the credential. + /// * `voucher_info`: Plaintext information of the credential. + /// * `serial_number`: Base58 representation of the serial number attribute. + /// * `binding_number`: Base58 representation of the binding number attribute. + /// * `signature`: Coconut credential in the form of a signature. + pub(crate) async fn insert_coconut_credential( + &self, + voucher_value: String, + voucher_info: String, + serial_number: String, + binding_number: String, + signature: String, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + "INSERT INTO coconut_credentials(voucher_value, voucher_info, serial_number, binding_number, signature) VALUES (?, ?, ?, ?, ?)", + voucher_value, voucher_info, serial_number, binding_number, signature + ) + .execute(&self.connection_pool) + .await?; + Ok(()) + } + + /// Tries to retrieve one of the stored, unused credentials. + pub(crate) async fn get_next_coconut_credential( + &self, + ) -> Result { + sqlx::query_as!(CoconutCredential, "SELECT * FROM coconut_credentials") + .fetch_one(&self.connection_pool) + .await + } + + /// Removes from the database the specified credential. + /// + /// # Arguments + /// + /// * `id`: Database id. + pub(crate) async fn remove_coconut_credential(&self, id: i64) -> Result<(), sqlx::Error> { + sqlx::query!("DELETE FROM coconut_credentials WHERE id = ?", id) + .execute(&self.connection_pool) + .await?; + Ok(()) + } +} diff --git a/common/credential-storage/src/erc20.rs b/common/credential-storage/src/erc20.rs new file mode 100644 index 0000000000..1c1fd85814 --- /dev/null +++ b/common/credential-storage/src/erc20.rs @@ -0,0 +1,71 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use crate::models::ERC20Credential; + +#[derive(Clone)] +pub(crate) struct ERC20CredentialManager { + connection_pool: sqlx::SqlitePool, +} + +impl ERC20CredentialManager { + /// Creates new instance of the `ERC20CredentialManager` with the provided sqlite connection pool. + /// + /// # Arguments + /// + /// * `connection_pool`: database connection pool to use. + pub(crate) fn new(connection_pool: sqlx::SqlitePool) -> Self { + ERC20CredentialManager { connection_pool } + } + + /// Inserts provided signature into the database. + /// + /// # Arguments + /// + /// * `public_key`: Base58 representation of a public key. + /// * `private_key`: Base58 representation of a private key. + pub(crate) async fn insert_erc20_credential( + &self, + public_key: String, + private_key: String, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + "INSERT INTO erc20_credentials(public_key, private_key, consumed) VALUES (?, ?, ?)", + public_key, + private_key, + false, + ) + .execute(&self.connection_pool) + .await?; + Ok(()) + } + + /// Tries to retrieve one of the stored, unused credentials. + pub(crate) async fn get_next_erc20_credential(&self) -> Result { + sqlx::query_as!( + ERC20Credential, + "SELECT * FROM erc20_credentials WHERE consumed = false" + ) + .fetch_one(&self.connection_pool) + .await + } + + /// Mark a credential as being consumed. + pub(crate) async fn consume_erc20_credential( + &self, + public_key: String, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + UPDATE erc20_credentials + SET consumed = true + WHERE public_key = ? + "#, + public_key + ) + .execute(&self.connection_pool) + .await?; + + Ok(()) + } +} diff --git a/common/credential-storage/src/error.rs b/common/credential-storage/src/error.rs new file mode 100644 index 0000000000..fbd9a347f4 --- /dev/null +++ b/common/credential-storage/src/error.rs @@ -0,0 +1,16 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum StorageError { + #[error("Database experienced an internal error - {0}")] + InternalDatabaseError(#[from] sqlx::Error), + + #[error("Failed to perform database migration - {0}")] + MigrationError(#[from] sqlx::migrate::MigrateError), + + #[error("Inconsistent data in database")] + InconsistentData, +} diff --git a/common/credential-storage/src/lib.rs b/common/credential-storage/src/lib.rs new file mode 100644 index 0000000000..05cd5e7988 --- /dev/null +++ b/common/credential-storage/src/lib.rs @@ -0,0 +1,144 @@ +/* + * Copyright 2022 - Nym Technologies SA + * SPDX-License-Identifier: Apache-2.0 + */ + +use crate::coconut::CoconutCredentialManager; +use crate::erc20::ERC20CredentialManager; +use crate::error::StorageError; +use crate::storage::Storage; + +use crate::models::{CoconutCredential, ERC20Credential}; +use async_trait::async_trait; +use log::{debug, error}; +use sqlx::ConnectOptions; +use std::path::{Path, PathBuf}; + +mod coconut; +mod erc20; +pub mod error; +mod models; +pub mod storage; + +// note that clone here is fine as upon cloning the same underlying pool will be used +#[derive(Clone)] +pub struct PersistentStorage { + coconut_credential_manager: CoconutCredentialManager, + erc20_credential_manager: ERC20CredentialManager, +} + +impl PersistentStorage { + /// Initialises `PersistentStorage` using the provided path. + /// + /// # Arguments + /// + /// * `database_path`: path to the database. + pub async fn init + Send>(database_path: P) -> Result { + debug!( + "Attempting to connect to database {:?}", + database_path.as_ref().as_os_str() + ); + + let mut opts = sqlx::sqlite::SqliteConnectOptions::new() + .filename(database_path) + .create_if_missing(true); + + opts.disable_statement_logging(); + + let connection_pool = match sqlx::SqlitePool::connect_with(opts).await { + Ok(db) => db, + Err(err) => { + error!("Failed to connect to SQLx database: {}", err); + return Err(err.into()); + } + }; + + if let Err(err) = sqlx::migrate!("./migrations").run(&connection_pool).await { + error!("Failed to perform migration on the SQLx database: {}", err); + return Err(err.into()); + } + + Ok(PersistentStorage { + coconut_credential_manager: CoconutCredentialManager::new(connection_pool.clone()), + erc20_credential_manager: ERC20CredentialManager::new(connection_pool), + }) + } +} + +#[async_trait] +impl Storage for PersistentStorage { + async fn insert_coconut_credential( + &self, + voucher_value: String, + voucher_info: String, + serial_number: String, + binding_number: String, + signature: String, + ) -> Result<(), StorageError> { + self.coconut_credential_manager + .insert_coconut_credential( + voucher_value, + voucher_info, + serial_number, + binding_number, + signature, + ) + .await?; + + Ok(()) + } + + async fn get_next_coconut_credential(&self) -> Result { + let credential = self + .coconut_credential_manager + .get_next_coconut_credential() + .await?; + + Ok(credential) + } + + async fn remove_coconut_credential(&self, id: i64) -> Result<(), StorageError> { + self.coconut_credential_manager + .remove_coconut_credential(id) + .await?; + + Ok(()) + } + + async fn insert_erc20_credential( + &self, + public_key: String, + private_key: String, + ) -> Result<(), StorageError> { + self.erc20_credential_manager + .insert_erc20_credential(public_key, private_key) + .await?; + + Ok(()) + } + + async fn get_next_erc20_credential(&self) -> Result { + let credential = self + .erc20_credential_manager + .get_next_erc20_credential() + .await?; + + Ok(credential) + } + + async fn consume_erc20_credential(&self, public_key: String) -> Result<(), StorageError> { + let credential = self + .erc20_credential_manager + .consume_erc20_credential(public_key) + .await?; + + Ok(credential) + } +} + +pub async fn initialise_storage(path: PathBuf) -> PersistentStorage { + match PersistentStorage::init(path).await { + Err(err) => panic!("failed to initialise credential storage - {}", err), + Ok(storage) => storage, + } +} diff --git a/common/credential-storage/src/models.rs b/common/credential-storage/src/models.rs new file mode 100644 index 0000000000..ee480a2034 --- /dev/null +++ b/common/credential-storage/src/models.rs @@ -0,0 +1,20 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +pub struct CoconutCredential { + #[allow(dead_code)] + pub id: i64, + pub voucher_value: String, + pub voucher_info: String, + pub serial_number: String, + pub binding_number: String, + pub signature: String, +} + +pub struct ERC20Credential { + #[allow(dead_code)] + pub id: i64, + pub public_key: String, + pub private_key: String, + pub consumed: bool, +} diff --git a/common/credential-storage/src/storage.rs b/common/credential-storage/src/storage.rs new file mode 100644 index 0000000000..be15632ca1 --- /dev/null +++ b/common/credential-storage/src/storage.rs @@ -0,0 +1,52 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use async_trait::async_trait; + +use crate::models::{CoconutCredential, ERC20Credential}; +use crate::StorageError; + +#[async_trait] +pub trait Storage: Send + Sync { + /// Inserts provided signature into the database. + /// + /// # Arguments + /// + /// * `signature`: Coconut credential in the form of a signature. + async fn insert_coconut_credential( + &self, + voucher_value: String, + voucher_info: String, + serial_number: String, + binding_number: String, + signature: String, + ) -> Result<(), StorageError>; + + /// Tries to retrieve one of the stored, unused credentials. + async fn get_next_coconut_credential(&self) -> Result; + + /// Removes from the database the specified credential. + /// + /// # Arguments + /// + /// * `signature`: Coconut credential in the form of a signature. + async fn remove_coconut_credential(&self, id: i64) -> Result<(), StorageError>; + + /// Inserts provided signature into the database. + /// + /// # Arguments + /// + /// * `public_key`: Base58 representation of a public key. + /// * `private_key`: Base58 representation of a private key. + async fn insert_erc20_credential( + &self, + public_key: String, + private_key: String, + ) -> Result<(), StorageError>; + + /// Tries to retrieve one of the stored, unused credential data. + async fn get_next_erc20_credential(&self) -> Result; + + /// Mark a credential as being consumed. + async fn consume_erc20_credential(&self, public_key: String) -> Result<(), StorageError>; +} diff --git a/common/credentials/src/coconut/bandwidth.rs b/common/credentials/src/coconut/bandwidth.rs index d25187950a..23c357188d 100644 --- a/common/credentials/src/coconut/bandwidth.rs +++ b/common/credentials/src/coconut/bandwidth.rs @@ -11,7 +11,6 @@ use coconut_interface::{ PrivateAttribute, PublicAttribute, Signature, VerificationKey, }; use crypto::asymmetric::{encryption, identity}; -use network_defaults::BANDWIDTH_VALUE; use cosmrs::tx::Hash; @@ -159,29 +158,27 @@ impl BandwidthVoucher { pub fn sign(&self, request: &BlindSignRequest) -> identity::Signature { let mut message = request.to_bytes(); - message.extend_from_slice(self.tx_hash.as_bytes()); + message.extend_from_slice(self.tx_hash.to_string().as_bytes()); self.signing_key.sign(&message) } } pub fn prepare_for_spending( - raw_identity: &[u8], + voucher_value: u64, + voucher_info: String, + serial_number: PrivateAttribute, + binding_number: PrivateAttribute, signature: &Signature, - attributes: &BandwidthVoucher, verification_key: &VerificationKey, ) -> Result { - let public_attributes = vec![ - raw_identity.to_vec(), - BANDWIDTH_VALUE.to_be_bytes().to_vec(), - ]; - let params = Parameters::new(TOTAL_ATTRIBUTES)?; prepare_credential_for_spending( ¶ms, - public_attributes, - attributes.serial_number, - attributes.binding_number, + voucher_value, + voucher_info, + serial_number, + binding_number, signature, verification_key, ) diff --git a/common/credentials/src/coconut/utils.rs b/common/credentials/src/coconut/utils.rs index 7b61f70158..282614238e 100644 --- a/common/credentials/src/coconut/utils.rs +++ b/common/credentials/src/coconut/utils.rs @@ -11,7 +11,7 @@ use crypto::shared_key::recompute_shared_key; use crypto::symmetric::stream_cipher; use url::Url; -use crate::coconut::bandwidth::{BandwidthVoucher, PRIVATE_ATTRIBUTES}; +use crate::coconut::bandwidth::{BandwidthVoucher, PRIVATE_ATTRIBUTES, PUBLIC_ATTRIBUTES}; use crate::coconut::params::{ ValidatorApiCredentialEncryptionAlgorithm, ValidatorApiCredentialHkdfAlgorithm, }; @@ -175,7 +175,8 @@ pub async fn obtain_aggregate_signature( // TODO: better type flow pub fn prepare_credential_for_spending( params: &Parameters, - public_attributes: Vec>, + voucher_value: u64, + voucher_info: String, serial_number: Attribute, binding_number: Attribute, signature: &Signature, @@ -190,9 +191,10 @@ pub fn prepare_credential_for_spending( )?; Ok(Credential::new( - (public_attributes.len() + PRIVATE_ATTRIBUTES as usize) as u32, + PUBLIC_ATTRIBUTES + PRIVATE_ATTRIBUTES, theta, - public_attributes, + voucher_value.to_string(), + voucher_info, signature, )) } diff --git a/common/credentials/src/error.rs b/common/credentials/src/error.rs index e67e74f254..8f628ac863 100644 --- a/common/credentials/src/error.rs +++ b/common/credentials/src/error.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 #[cfg(feature = "coconut")] -use coconut_interface::CoconutError; +use coconut_interface::{error::CoconutInterfaceError, CoconutError}; use crypto::asymmetric::encryption::KeyRecoveryError; use validator_client::ValidatorClientError; @@ -17,18 +17,16 @@ pub enum Error { NoValidatorsAvailable, #[cfg(feature = "coconut")] - #[error("Run into a coconut error - {0}")] + #[error("Ran into a coconut error - {0}")] CoconutError(#[from] CoconutError), - #[error("Run into a validato client error - {0}")] + #[cfg(feature = "coconut")] + #[error("Ran into a coconut interface error - {0}")] + CoconutInterfaceError(#[from] CoconutInterfaceError), + + #[error("Ran into a validator client error - {0}")] ValidatorClientError(#[from] ValidatorClientError), - #[error("Not enough public attributes were specified")] - NotEnoughPublicAttributes, - - #[error("Bandwidth is expected to be represented on 8 bytes")] - InvalidBandwidthSize, - #[error("Bandwidth operation overflowed. {0}")] BandwidthOverflow(String), diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 546319ceae..5a2f7c1752 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -144,6 +144,12 @@ version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +[[package]] +name = "bytes" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" + [[package]] name = "cc" version = "1.0.72" @@ -200,6 +206,9 @@ dependencies = [ "config", "cosmwasm-std", "cosmwasm-storage", + "cw-controllers", + "cw-multi-test", + "cw-storage-plus", "schemars", "serde", "thiserror", @@ -209,6 +218,7 @@ dependencies = [ name = "coconut-bandwidth-contract-common" version = "0.1.0" dependencies = [ + "cosmwasm-std", "schemars", "serde", ] @@ -263,9 +273,9 @@ dependencies = [ [[package]] name = "cosmwasm-schema" -version = "1.0.0-beta3" +version = "1.0.0-beta7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818b928263c09a3269c2bed22494a62107a43ef87900e273af8ad2cb9f7e4440" +checksum = "63f79866e7b2190b6b6cb06959e308183c8d9511a8530f7292073f3cddc963db" dependencies = [ "schemars", "serde_json", @@ -381,6 +391,39 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cw-controllers" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc6d042b14823b0e9f33f5cdd67a1eb9b16a7d79f7547b1a73c8870b518b97b" +dependencies = [ + "cosmwasm-std", + "cw-storage-plus", + "cw-utils", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw-multi-test" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbea57e5be4a682268a5eca1a57efece57a54ff216bfd87603d5e864aad40e12" +dependencies = [ + "anyhow", + "cosmwasm-std", + "cosmwasm-storage", + "cw-storage-plus", + "cw-utils", + "derivative", + "itertools", + "prost", + "schemars", + "serde", + "thiserror", +] + [[package]] name = "cw-storage-plus" version = "0.13.2" @@ -392,6 +435,105 @@ dependencies = [ "serde", ] +[[package]] +name = "cw-utils" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "babd2c090f39d07ce5bf2556962305e795daa048ce20a93709eb591476e4a29e" +dependencies = [ + "cosmwasm-std", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw2" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d686da2d2b3b646bea15d448dc25c3e132097a7c40ef9ba7b4db741375b6181f" +dependencies = [ + "cosmwasm-std", + "cw-storage-plus", + "schemars", + "serde", +] + +[[package]] +name = "cw3" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7c7a87aed0637432c9ec39a691e533e18006dd400c67bce9f0ad643de7d52c" +dependencies = [ + "cosmwasm-std", + "cw-utils", + "schemars", + "serde", +] + +[[package]] +name = "cw3-fixed-multisig" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666afbb3bcaefc697047a0be8c4c5015be053a8456d27dd26e0b8ab114f6f5dd" +dependencies = [ + "cosmwasm-std", + "cw-storage-plus", + "cw-utils", + "cw2", + "cw3", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw3-flex-multisig" +version = "0.13.1" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-storage-plus", + "cw-utils", + "cw2", + "cw3", + "cw3-fixed-multisig", + "cw4", + "cw4-group", + "schemars", + "serde", + "thiserror", +] + +[[package]] +name = "cw4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d51b6e05094bfb91029f5baf5d1f39ee10f16fd61f8bf0e6f6632a5dbfb7f9" +dependencies = [ + "cosmwasm-std", + "cw-storage-plus", + "schemars", + "serde", +] + +[[package]] +name = "cw4-group" +version = "0.13.1" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers", + "cw-storage-plus", + "cw-utils", + "cw2", + "cw4", + "schemars", + "serde", + "thiserror", +] + [[package]] name = "der" version = "0.4.5" @@ -401,6 +543,17 @@ dependencies = [ "const-oid", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.8.1" @@ -475,6 +628,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" + [[package]] name = "elliptic-curve" version = "0.10.6" @@ -714,6 +873,15 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "itertools" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9a9d19fa1e79b6215ff29b9d6880b706147f16e9b1dbb1e4e5947b5b02bc5e3" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.1" @@ -1061,6 +1229,29 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "prost" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "444879275cb4fd84958b1a1d5420d15e6fcf7c235fe47f053c9c2a80aceb6001" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9cc1a3263e07e0bf68e96268f37665207b49560d98739662cdfaae215c720fe" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quick-error" version = "2.0.1" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index d748532df6..6d5188e04e 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["bandwidth-claim", "coconut-bandwidth", "mixnet", "vesting"] +members = ["bandwidth-claim", "coconut-bandwidth", "mixnet", "vesting", "multisig/cw3-flex-multisig", "multisig/cw4-group"] [profile.release] opt-level = 3 diff --git a/contracts/coconut-bandwidth/Cargo.toml b/contracts/coconut-bandwidth/Cargo.toml index 234e5ced4b..74466d70d1 100644 --- a/contracts/coconut-bandwidth/Cargo.toml +++ b/contracts/coconut-bandwidth/Cargo.toml @@ -13,9 +13,14 @@ bandwidth-claim-contract = { path = "../../common/bandwidth-claim-contract" } coconut-bandwidth-contract-common = { path = "../../common/cosmwasm-smart-contracts/coconut-bandwidth-contract" } config = { path = "../../common/config"} -cosmwasm-std = "1.0.0-beta3" -cosmwasm-storage = "1.0.0-beta3" +cosmwasm-std = "1.0.0-beta8" +cosmwasm-storage = "1.0.0-beta8" +cw-storage-plus = "0.13.2" +cw-controllers = "0.13.2" schemars = "0.8" serde = { version = "1.0.103", default-features = false, features = ["derive"] } thiserror = "1.0.23" + +[dev-dependencies] +cw-multi-test = { version = "0.13.2" } diff --git a/contracts/coconut-bandwidth/src/contract.rs b/contracts/coconut-bandwidth/src/contract.rs new file mode 100644 index 0000000000..47e9087e6d --- /dev/null +++ b/contracts/coconut-bandwidth/src/contract.rs @@ -0,0 +1,163 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_std::{entry_point, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; + +use coconut_bandwidth_contract_common::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +use crate::error::ContractError; +use crate::state::{Config, ADMIN, CONFIG}; +use crate::transactions; + +/// Instantiate the contract. +/// +/// `deps` contains Storage, API and Querier +/// `msg` is the contract initialization message, sort of like a constructor call. +#[entry_point] +pub fn instantiate( + mut deps: DepsMut<'_>, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let multisig_addr = deps.api.addr_validate(&msg.multisig_addr)?; + let pool_addr = deps.api.addr_validate(&msg.pool_addr)?; + + ADMIN.set(deps.branch(), Some(multisig_addr.clone()))?; + + let cfg = Config { + multisig_addr, + pool_addr, + }; + CONFIG.save(deps.storage, &cfg)?; + + Ok(Response::default()) +} + +/// Handle an incoming message +#[entry_point] +pub fn execute( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::DepositFunds { data } => transactions::deposit_funds(deps, env, info, data), + ExecuteMsg::ReleaseFunds { funds } => transactions::release_funds(deps, env, info, funds), + } +} + +#[entry_point] +pub fn query(_deps: Deps, _env: Env, _msg: QueryMsg) -> StdResult { + unimplemented!(); +} + +#[entry_point] +pub fn migrate(_deps: DepsMut<'_>, _env: Env, _msg: MigrateMsg) -> Result { + Ok(Default::default()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::support::tests::helpers::*; + use coconut_bandwidth_contract_common::deposit::DepositData; + use config::defaults::DENOM; + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + use cosmwasm_std::{coins, Addr}; + use cw_multi_test::Executor; + use serde::de::Unexpected::Str; + + #[test] + fn initialize_contract() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let msg = InstantiateMsg { + multisig_addr: String::from(MULTISIG_CONTRACT), + pool_addr: String::from(POOL_CONTRACT), + }; + let info = mock_info("creator", &[]); + + let res = instantiate(deps.as_mut(), env.clone(), info, msg).unwrap(); + assert_eq!(0, res.messages.len()); + + // Contract balance should be 0 + assert_eq!( + coins(0, DENOM), + vec![deps + .as_ref() + .querier + .query_balance(env.contract.address, DENOM) + .unwrap()] + ); + } + + #[test] + fn deposit_and_release() { + let init_funds = coins(10, DENOM); + let deposit_funds = coins(1, DENOM); + let release_funds = coins(2, DENOM); + let mut app = mock_app(&init_funds); + let multisig_addr = String::from(MULTISIG_CONTRACT); + let pool_addr = String::from(POOL_CONTRACT); + + let code_id = app.store_code(contract_bandwidth()); + let msg = InstantiateMsg { + multisig_addr: multisig_addr.clone(), + pool_addr: pool_addr.clone(), + }; + let contract_addr = app + .instantiate_contract( + code_id, + Addr::unchecked(OWNER), + &msg, + &[], + "bandwidth", + None, + ) + .unwrap(); + + let msg = ExecuteMsg::DepositFunds { + data: DepositData::new( + String::from("info"), + String::from("id"), + String::from("enc"), + ), + }; + app.execute_contract( + Addr::unchecked(OWNER), + contract_addr.clone(), + &msg, + &deposit_funds, + ) + .unwrap(); + + // try to release more then it's in the contract + let msg = ExecuteMsg::ReleaseFunds { + funds: release_funds[0].clone(), + }; + let err = app + .execute_contract( + Addr::unchecked(multisig_addr.clone()), + contract_addr.clone(), + &msg, + &[], + ) + .unwrap_err(); + assert_eq!(ContractError::NotEnoughFunds, err.downcast().unwrap()); + + let msg = ExecuteMsg::ReleaseFunds { + funds: deposit_funds[0].clone(), + }; + app.execute_contract( + Addr::unchecked(multisig_addr), + contract_addr.clone(), + &msg, + &[], + ) + .unwrap(); + let pool_bal = app.wrap().query_balance(pool_addr, DENOM).unwrap(); + assert_eq!(pool_bal, deposit_funds[0]); + } +} diff --git a/contracts/coconut-bandwidth/src/error.rs b/contracts/coconut-bandwidth/src/error.rs index 4c10be3783..8545a41fce 100644 --- a/contracts/coconut-bandwidth/src/error.rs +++ b/contracts/coconut-bandwidth/src/error.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use cosmwasm_std::StdError; +use cw_controllers::AdminError; use thiserror::Error; use config::defaults::DENOM; @@ -23,4 +24,10 @@ pub enum ContractError { #[error("Wrong coin denomination, you must send {}", DENOM)] WrongDenom, + + #[error("There aren't enough funds in the contract")] + NotEnoughFunds, + + #[error("{0}")] + Admin(#[from] AdminError), } diff --git a/contracts/coconut-bandwidth/src/lib.rs b/contracts/coconut-bandwidth/src/lib.rs index 133ffb1999..af62e012fa 100644 --- a/contracts/coconut-bandwidth/src/lib.rs +++ b/contracts/coconut-bandwidth/src/lib.rs @@ -1,73 +1,8 @@ // Copyright 2021 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +pub mod contract; mod error; +mod state; mod support; mod transactions; - -use cosmwasm_std::{entry_point, DepsMut, Env, MessageInfo, Response}; - -use crate::error::ContractError; -use coconut_bandwidth_contract_common::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg}; - -/// Instantiate the contract. -/// -/// `deps` contains Storage, API and Querier -/// `env` contains block, message and contract info -/// `msg` is the contract initialization message, sort of like a constructor call. -#[entry_point] -pub fn instantiate( - _deps: DepsMut<'_>, - _env: Env, - _info: MessageInfo, - _msg: InstantiateMsg, -) -> Result { - Ok(Response::default()) -} - -/// Handle an incoming message -#[entry_point] -pub fn execute( - deps: DepsMut<'_>, - env: Env, - info: MessageInfo, - msg: ExecuteMsg, -) -> Result { - match msg { - ExecuteMsg::DepositFunds { data } => transactions::deposit_funds(deps, env, info, data), - } -} - -#[entry_point] -pub fn migrate(_deps: DepsMut<'_>, _env: Env, _msg: MigrateMsg) -> Result { - Ok(Default::default()) -} - -#[cfg(test)] -mod tests { - use super::*; - use config::defaults::DENOM; - use cosmwasm_std::coins; - use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; - - #[test] - fn initialize_contract() { - let mut deps = mock_dependencies(); - let env = mock_env(); - let msg = InstantiateMsg {}; - let info = mock_info("creator", &[]); - - let res = instantiate(deps.as_mut(), env.clone(), info, msg).unwrap(); - assert_eq!(0, res.messages.len()); - - // Contract balance should be 0 - assert_eq!( - coins(0, DENOM), - vec![deps - .as_ref() - .querier - .query_balance(env.contract.address, DENOM) - .unwrap()] - ); - } -} diff --git a/contracts/coconut-bandwidth/src/state.rs b/contracts/coconut-bandwidth/src/state.rs new file mode 100644 index 0000000000..0ac612de34 --- /dev/null +++ b/contracts/coconut-bandwidth/src/state.rs @@ -0,0 +1,18 @@ +// Copyright 2022 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use cosmwasm_std::Addr; +use cw_controllers::Admin; +use cw_storage_plus::Item; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub const ADMIN: Admin = Admin::new("admin"); + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct Config { + pub multisig_addr: Addr, + pub pool_addr: Addr, +} + +pub const CONFIG: Item = Item::new("config"); diff --git a/contracts/coconut-bandwidth/src/support/tests.rs b/contracts/coconut-bandwidth/src/support/tests.rs index 352f75439b..4d21c67d46 100644 --- a/contracts/coconut-bandwidth/src/support/tests.rs +++ b/contracts/coconut-bandwidth/src/support/tests.rs @@ -3,17 +3,44 @@ #[cfg(test)] pub mod helpers { - use crate::instantiate; + pub const OWNER: &str = "admin0001"; + pub const SOMEBODY: &str = "somebody"; + pub const MULTISIG_CONTRACT: &str = "multisig contract address"; + pub const POOL_CONTRACT: &str = "mix pool contract address"; + + use crate::contract::instantiate; use coconut_bandwidth_contract_common::msg::InstantiateMsg; use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info, MockApi, MockQuerier}; - use cosmwasm_std::{Empty, MemoryStorage, OwnedDeps}; + use cosmwasm_std::{Addr, Coin, Empty, MemoryStorage, OwnedDeps}; + use cw_multi_test::{App, AppBuilder, Contract, ContractWrapper}; pub fn init_contract() -> OwnedDeps> { let mut deps = mock_dependencies(); - let msg = InstantiateMsg {}; + let msg = InstantiateMsg { + multisig_addr: String::from(MULTISIG_CONTRACT), + pool_addr: String::from(POOL_CONTRACT), + }; let env = mock_env(); let info = mock_info("creator", &[]); instantiate(deps.as_mut(), env.clone(), info, msg).unwrap(); return deps; } + + pub fn mock_app(init_funds: &[Coin]) -> App { + AppBuilder::new().build(|router, _, storage| { + router + .bank + .init_balance(storage, &Addr::unchecked(OWNER), init_funds.to_vec()) + .unwrap(); + }) + } + + pub fn contract_bandwidth() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ); + Box::new(contract) + } } diff --git a/contracts/coconut-bandwidth/src/transactions.rs b/contracts/coconut-bandwidth/src/transactions.rs index 202ff71bb2..2cfa5cce46 100644 --- a/contracts/coconut-bandwidth/src/transactions.rs +++ b/contracts/coconut-bandwidth/src/transactions.rs @@ -1,9 +1,11 @@ // Copyright 2021 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use cosmwasm_std::{DepsMut, Env, Event, MessageInfo, Response}; +use cosmwasm_std::{BankMsg, Coin, DepsMut, Env, Event, MessageInfo, Response}; use crate::error::ContractError; +use crate::state::{ADMIN, CONFIG}; + use coconut_bandwidth_contract_common::deposit::DepositData; use coconut_bandwidth_contract_common::events::{ DEPOSITED_FUNDS_EVENT_TYPE, DEPOSIT_ENCRYPTION_KEY, DEPOSIT_IDENTITY_KEY, DEPOSIT_INFO, @@ -37,12 +39,40 @@ pub(crate) fn deposit_funds( Ok(Response::new().add_event(event)) } +pub(crate) fn release_funds( + deps: DepsMut<'_>, + env: Env, + info: MessageInfo, + funds: Coin, +) -> Result { + if funds.denom != DENOM { + return Err(ContractError::WrongDenom); + } + let current_balance = deps.querier.query_balance(env.contract.address, DENOM)?; + if funds.amount > current_balance.amount { + return Err(ContractError::NotEnoughFunds); + } + ADMIN.assert_admin(deps.as_ref(), &info.sender)?; + + let cfg = CONFIG.load(deps.storage)?; + + let return_tokens = BankMsg::Send { + to_address: cfg.pool_addr.into(), + amount: vec![funds], + }; + let response = Response::new().add_message(return_tokens); + + Ok(response) +} + #[cfg(test)] mod tests { use super::*; use crate::support::tests::helpers; + use crate::support::tests::helpers::{MULTISIG_CONTRACT, POOL_CONTRACT}; use cosmwasm_std::testing::{mock_env, mock_info}; - use cosmwasm_std::Coin; + use cosmwasm_std::{Coin, CosmosMsg}; + use cw_controllers::AdminError; #[test] fn invalid_deposit() { @@ -133,4 +163,65 @@ mod tests { .unwrap(); assert_eq!(encryption_key_attr.value, encryption_key); } + + #[test] + fn invalid_release() { + let mut deps = helpers::init_contract(); + let env = mock_env(); + let invalid_admin = "invalid admin"; + let funds = Coin::new(1, DENOM); + + let err = release_funds( + deps.as_mut(), + env.clone(), + mock_info(invalid_admin, &[]), + Coin::new(1, "invalid denom"), + ) + .unwrap_err(); + assert_eq!(err, ContractError::WrongDenom); + + let err = release_funds( + deps.as_mut(), + env.clone(), + mock_info(invalid_admin, &[]), + funds.clone(), + ) + .unwrap_err(); + assert_eq!(err, ContractError::NotEnoughFunds); + + deps.querier + .update_balance(env.contract.address.clone(), vec![funds.clone()]); + let err = release_funds( + deps.as_mut(), + env.clone(), + mock_info(invalid_admin, &[]), + funds.clone(), + ) + .unwrap_err(); + assert_eq!(err, ContractError::Admin(AdminError::NotAdmin {})); + } + + #[test] + fn valid_release() { + let mut deps = helpers::init_contract(); + let env = mock_env(); + let coin = Coin::new(1, DENOM); + + deps.querier + .update_balance(env.contract.address.clone(), vec![coin.clone()]); + let res = release_funds( + deps.as_mut(), + env, + mock_info(MULTISIG_CONTRACT, &[]), + coin.clone(), + ) + .unwrap(); + assert_eq!( + res.messages[0].msg, + CosmosMsg::Bank(BankMsg::Send { + to_address: String::from(POOL_CONTRACT), + amount: vec![coin] + }) + ); + } } diff --git a/contracts/multisig/cw3-flex-multisig/.cargo/config b/contracts/multisig/cw3-flex-multisig/.cargo/config new file mode 100644 index 0000000000..8d4bc738b1 --- /dev/null +++ b/contracts/multisig/cw3-flex-multisig/.cargo/config @@ -0,0 +1,6 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +wasm-debug = "build --target wasm32-unknown-unknown" +unit-test = "test --lib" +integration-test = "test --test integration" +schema = "run --example schema" diff --git a/contracts/multisig/cw3-flex-multisig/Cargo.toml b/contracts/multisig/cw3-flex-multisig/Cargo.toml new file mode 100644 index 0000000000..86ae1f6450 --- /dev/null +++ b/contracts/multisig/cw3-flex-multisig/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "cw3-flex-multisig" +version = "0.13.1" +authors = ["Ethan Frey "] +edition = "2018" +description = "Implementing cw3 with multiple voting patterns and dynamic groups" +license = "Apache-2.0" +repository = "https://github.com/CosmWasm/cw-plus" +homepage = "https://cosmwasm.com" +documentation = "https://docs.cosmwasm.com" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cw-utils = { version = "0.13.1" } +cw2 = { version = "0.13.1" } +cw3 = { version = "0.13.1" } +cw3-fixed-multisig = { version = "0.13.1", features = ["library"] } +cw4 = { version = "0.13.1" } +cw-storage-plus = { version = "0.13.1" } +cosmwasm-std = { version = "1.0.0-beta6" } +schemars = "0.8.1" +serde = { version = "1.0.103", default-features = false, features = ["derive"] } +thiserror = { version = "1.0.23" } + +[dev-dependencies] +cosmwasm-schema = { version = "1.0.0-beta6" } +cw4-group = { path = "../cw4-group", version = "0.13.1" } +cw-multi-test = { version = "0.13.1" } diff --git a/contracts/multisig/cw3-flex-multisig/NOTICE b/contracts/multisig/cw3-flex-multisig/NOTICE new file mode 100644 index 0000000000..8f613f7be6 --- /dev/null +++ b/contracts/multisig/cw3-flex-multisig/NOTICE @@ -0,0 +1,14 @@ +CW3-Flex-Whitelist: Implementing cw3 with multiple voting patterns and dynamic groups +Copyright (C) 2020 Confio OÜ + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/contracts/multisig/cw3-flex-multisig/README.md b/contracts/multisig/cw3-flex-multisig/README.md new file mode 100644 index 0000000000..3225d94d12 --- /dev/null +++ b/contracts/multisig/cw3-flex-multisig/README.md @@ -0,0 +1,85 @@ +# CW3 Flexible Multisig + +This builds on [cw3-fixed-multisig](../cw3-fixed-multisig) with a more +powerful implementation of the [cw3 spec](../../packages/cw3/README.md). +It is a multisig contract that is backed by a +[cw4 (group)](../../packages/cw4/README.md) contract, which independently +maintains the voter set. + +This provides 2 main advantages: + +* You can create two different multisigs with different voting thresholds + backed by the same group. Thus, you can have a 50% vote, and a 67% vote + that always use the same voter set, but can take other actions. +* TODO: It allows dynamic multisig groups. Since the group can change, + we can set one of the multisigs as the admin of the group contract, + and the + + +In addition to the dynamic voting set, the main difference with the native +Cosmos SDK multisig, is that it aggregates the signatures on chain, with +visible proposals (like `x/gov` in the Cosmos SDK), rather than requiring +signers to share signatures off chain. + +## Instantiation + +The first step to create such a multisig is to instantiate a cw4 contract +with the desired member set. For now, this only is supported by +[cw4-group](../cw4-group), but we will add a token-weighted group contract +(TODO). + +If you create a `cw4-group` contract and want a multisig to be able +to modify its own group, do the following in multiple transactions: + + * instantiate cw4-group, with your personal key as admin + * instantiate a multisig pointing to the group + * `AddHook{multisig}` on the group contract + * `UpdateAdmin{multisig}` on the group contract + +This is the current practice to create such circular dependencies, +and depends on an external driver (hard to impossible to script such a +self-deploying contract on-chain). (TODO: document better). + +When creating the multisig, you must set the required weight to pass a vote +as well as the max/default voting period. (TODO: allow more threshold types) + +## Execution Process + +First, a registered voter must submit a proposal. This also includes the +first "Yes" vote on the proposal by the proposer. The proposer can set +an expiration time for the voting process, or it defaults to the limit +provided when creating the contract (so proposals can be closed after several +days). + +Before the proposal has expired, any voter with non-zero weight can add their +vote. Only "Yes" votes are tallied. If enough "Yes" votes were submitted before +the proposal expiration date, the status is set to "Passed". + +Once a proposal is "Passed", anyone may submit an "Execute" message. This will +trigger the proposal to send all stored messages from the proposal and update +it's state to "Executed", so it cannot run again. (Note if the execution fails +for any reason - out of gas, insufficient funds, etc - the state update will +be reverted, and it will remain "Passed", so you can try again). + +Once a proposal has expired without passing, anyone can submit a "Close" +message to mark it closed. This has no effect beyond cleaning up the UI/database. + +## Running this contract + +You will need Rust 1.44.1+ with `wasm32-unknown-unknown` target installed. + +You can run unit tests on this via: + +`cargo test` + +Once you are happy with the content, you can compile it to wasm via: + +``` +RUSTFLAGS='-C link-arg=-s' cargo wasm +cp ../../target/wasm32-unknown-unknown/release/cw3_fixed_multisig.wasm . +ls -l cw3_fixed_multisig.wasm +sha256sum cw3_fixed_multisig.wasm +``` + +Or for a production-ready (optimized) build, run a build command in +the repository root: https://github.com/CosmWasm/cw-plus#compiling. diff --git a/contracts/multisig/cw3-flex-multisig/src/contract.rs b/contracts/multisig/cw3-flex-multisig/src/contract.rs new file mode 100644 index 0000000000..5616cba7dd --- /dev/null +++ b/contracts/multisig/cw3-flex-multisig/src/contract.rs @@ -0,0 +1,1775 @@ +use std::cmp::Ordering; + +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_binary, Binary, BlockInfo, CosmosMsg, Deps, DepsMut, Empty, Env, MessageInfo, Order, + Response, StdResult, +}; + +use cw2::set_contract_version; +use cw3::{ + ProposalListResponse, ProposalResponse, Status, Vote, VoteInfo, VoteListResponse, VoteResponse, + VoterDetail, VoterListResponse, VoterResponse, +}; +use cw3_fixed_multisig::state::{next_id, Ballot, Proposal, Votes, BALLOTS, PROPOSALS}; +use cw4::{Cw4Contract, MemberChangedHookMsg, MemberDiff}; +use cw_storage_plus::Bound; +use cw_utils::{maybe_addr, Expiration, ThresholdResponse}; + +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use crate::state::{Config, CONFIG}; + +// version info for migration info +const CONTRACT_NAME: &str = "crates.io:cw3-flex-multisig"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let group_addr = Cw4Contract(deps.api.addr_validate(&msg.group_addr).map_err(|_| { + ContractError::InvalidGroup { + addr: msg.group_addr.clone(), + } + })?); + let total_weight = group_addr.total_weight(&deps.querier)?; + msg.threshold.validate(total_weight)?; + + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let cfg = Config { + threshold: msg.threshold, + max_voting_period: msg.max_voting_period, + group_addr, + }; + CONFIG.save(deps.storage, &cfg)?; + + Ok(Response::default()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result, ContractError> { + match msg { + ExecuteMsg::Propose { + title, + description, + msgs, + latest, + } => execute_propose(deps, env, info, title, description, msgs, latest), + ExecuteMsg::Vote { proposal_id, vote } => execute_vote(deps, env, info, proposal_id, vote), + ExecuteMsg::Execute { proposal_id } => execute_execute(deps, env, info, proposal_id), + ExecuteMsg::Close { proposal_id } => execute_close(deps, env, info, proposal_id), + ExecuteMsg::MemberChangedHook(MemberChangedHookMsg { diffs }) => { + execute_membership_hook(deps, env, info, diffs) + } + } +} + +pub fn execute_propose( + deps: DepsMut, + env: Env, + info: MessageInfo, + title: String, + description: String, + msgs: Vec, + // we ignore earliest + latest: Option, +) -> Result, ContractError> { + // only members of the multisig can create a proposal + let cfg = CONFIG.load(deps.storage)?; + + // Only members of the multisig can create a proposal + // Non-voting members are special - they are allowed to create a proposal and + // therefore "vote", but they aren't allowed to vote otherwise. + // Such vote is also special, because despite having 0 weight it still counts when + // counting threshold passing + let vote_power = cfg + .group_addr + .is_member(&deps.querier, &info.sender, None)? + .ok_or(ContractError::Unauthorized {})?; + + // max expires also used as default + let max_expires = cfg.max_voting_period.after(&env.block); + let mut expires = latest.unwrap_or(max_expires); + let comp = expires.partial_cmp(&max_expires); + if let Some(Ordering::Greater) = comp { + expires = max_expires; + } else if comp.is_none() { + return Err(ContractError::WrongExpiration {}); + } + + // create a proposal + let mut prop = Proposal { + title, + description, + start_height: env.block.height, + expires, + msgs, + status: Status::Open, + votes: Votes::yes(vote_power), + threshold: cfg.threshold, + total_weight: cfg.group_addr.total_weight(&deps.querier)?, + }; + prop.update_status(&env.block); + let id = next_id(deps.storage)?; + PROPOSALS.save(deps.storage, id, &prop)?; + + // add the first yes vote from voter + let ballot = Ballot { + weight: vote_power, + vote: Vote::Yes, + }; + BALLOTS.save(deps.storage, (id, &info.sender), &ballot)?; + + Ok(Response::new() + .add_attribute("action", "propose") + .add_attribute("sender", info.sender) + .add_attribute("proposal_id", id.to_string()) + .add_attribute("status", format!("{:?}", prop.status))) +} + +pub fn execute_vote( + deps: DepsMut, + env: Env, + info: MessageInfo, + proposal_id: u64, + vote: Vote, +) -> Result, ContractError> { + // only members of the multisig can vote + let cfg = CONFIG.load(deps.storage)?; + + // ensure proposal exists and can be voted on + let mut prop = PROPOSALS.load(deps.storage, proposal_id)?; + if prop.status != Status::Open { + return Err(ContractError::NotOpen {}); + } + if prop.expires.is_expired(&env.block) { + return Err(ContractError::Expired {}); + } + + // Only voting members of the multisig can vote + // Additional check if weight >= 1 + // use a snapshot of "start of proposal" + let vote_power = cfg + .group_addr + .is_voting_member(&deps.querier, &info.sender, prop.start_height)? + .ok_or(ContractError::Unauthorized {})?; + + // cast vote if no vote previously cast + BALLOTS.update(deps.storage, (proposal_id, &info.sender), |bal| match bal { + Some(_) => Err(ContractError::AlreadyVoted {}), + None => Ok(Ballot { + weight: vote_power, + vote, + }), + })?; + + // update vote tally + prop.votes.add_vote(vote, vote_power); + prop.update_status(&env.block); + PROPOSALS.save(deps.storage, proposal_id, &prop)?; + + Ok(Response::new() + .add_attribute("action", "vote") + .add_attribute("sender", info.sender) + .add_attribute("proposal_id", proposal_id.to_string()) + .add_attribute("status", format!("{:?}", prop.status))) +} + +pub fn execute_execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + proposal_id: u64, +) -> Result { + // anyone can trigger this if the vote passed + + let mut prop = PROPOSALS.load(deps.storage, proposal_id)?; + // we allow execution even after the proposal "expiration" as long as all vote come in before + // that point. If it was approved on time, it can be executed any time. + if prop.current_status(&env.block) != Status::Passed { + return Err(ContractError::WrongExecuteStatus {}); + } + + // set it to executed + prop.status = Status::Executed; + PROPOSALS.save(deps.storage, proposal_id, &prop)?; + + // dispatch all proposed messages + Ok(Response::new() + .add_messages(prop.msgs) + .add_attribute("action", "execute") + .add_attribute("sender", info.sender) + .add_attribute("proposal_id", proposal_id.to_string())) +} + +pub fn execute_close( + deps: DepsMut, + env: Env, + info: MessageInfo, + proposal_id: u64, +) -> Result, ContractError> { + // anyone can trigger this if the vote passed + + let mut prop = PROPOSALS.load(deps.storage, proposal_id)?; + if [Status::Executed, Status::Rejected, Status::Passed] + .iter() + .any(|x| *x == prop.status) + { + return Err(ContractError::WrongCloseStatus {}); + } + if !prop.expires.is_expired(&env.block) { + return Err(ContractError::NotExpired {}); + } + + // set it to failed + prop.status = Status::Rejected; + PROPOSALS.save(deps.storage, proposal_id, &prop)?; + + Ok(Response::new() + .add_attribute("action", "close") + .add_attribute("sender", info.sender) + .add_attribute("proposal_id", proposal_id.to_string())) +} + +pub fn execute_membership_hook( + deps: DepsMut, + _env: Env, + info: MessageInfo, + _diffs: Vec, +) -> Result, ContractError> { + // This is now a no-op + // But we leave the authorization check as a demo + let cfg = CONFIG.load(deps.storage)?; + if info.sender != cfg.group_addr.0 { + return Err(ContractError::Unauthorized {}); + } + + Ok(Response::default()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Threshold {} => to_binary(&query_threshold(deps)?), + QueryMsg::Proposal { proposal_id } => to_binary(&query_proposal(deps, env, proposal_id)?), + QueryMsg::Vote { proposal_id, voter } => to_binary(&query_vote(deps, proposal_id, voter)?), + QueryMsg::ListProposals { start_after, limit } => { + to_binary(&list_proposals(deps, env, start_after, limit)?) + } + QueryMsg::ReverseProposals { + start_before, + limit, + } => to_binary(&reverse_proposals(deps, env, start_before, limit)?), + QueryMsg::ListVotes { + proposal_id, + start_after, + limit, + } => to_binary(&list_votes(deps, proposal_id, start_after, limit)?), + QueryMsg::Voter { address } => to_binary(&query_voter(deps, address)?), + QueryMsg::ListVoters { start_after, limit } => { + to_binary(&list_voters(deps, start_after, limit)?) + } + } +} + +fn query_threshold(deps: Deps) -> StdResult { + let cfg = CONFIG.load(deps.storage)?; + let total_weight = cfg.group_addr.total_weight(&deps.querier)?; + Ok(cfg.threshold.to_response(total_weight)) +} + +fn query_proposal(deps: Deps, env: Env, id: u64) -> StdResult { + let prop = PROPOSALS.load(deps.storage, id)?; + let status = prop.current_status(&env.block); + let threshold = prop.threshold.to_response(prop.total_weight); + Ok(ProposalResponse { + id, + title: prop.title, + description: prop.description, + msgs: prop.msgs, + status, + expires: prop.expires, + threshold, + }) +} + +// settings for pagination +const MAX_LIMIT: u32 = 30; +const DEFAULT_LIMIT: u32 = 10; + +fn list_proposals( + deps: Deps, + env: Env, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(Bound::exclusive); + let proposals = PROPOSALS + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|p| map_proposal(&env.block, p)) + .collect::>()?; + + Ok(ProposalListResponse { proposals }) +} + +fn reverse_proposals( + deps: Deps, + env: Env, + start_before: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let end = start_before.map(Bound::exclusive); + let props: StdResult> = PROPOSALS + .range(deps.storage, None, end, Order::Descending) + .take(limit) + .map(|p| map_proposal(&env.block, p)) + .collect(); + + Ok(ProposalListResponse { proposals: props? }) +} + +fn map_proposal( + block: &BlockInfo, + item: StdResult<(u64, Proposal)>, +) -> StdResult { + item.map(|(id, prop)| { + let status = prop.current_status(block); + let threshold = prop.threshold.to_response(prop.total_weight); + ProposalResponse { + id, + title: prop.title, + description: prop.description, + msgs: prop.msgs, + status, + expires: prop.expires, + threshold, + } + }) +} + +fn query_vote(deps: Deps, proposal_id: u64, voter: String) -> StdResult { + let voter_addr = deps.api.addr_validate(&voter)?; + let prop = BALLOTS.may_load(deps.storage, (proposal_id, &voter_addr))?; + let vote = prop.map(|b| VoteInfo { + proposal_id, + voter, + vote: b.vote, + weight: b.weight, + }); + Ok(VoteResponse { vote }) +} + +fn list_votes( + deps: Deps, + proposal_id: u64, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let addr = maybe_addr(deps.api, start_after)?; + let start = addr.as_ref().map(Bound::exclusive); + + let votes = BALLOTS + .prefix(proposal_id) + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| { + item.map(|(addr, ballot)| VoteInfo { + proposal_id, + voter: addr.into(), + vote: ballot.vote, + weight: ballot.weight, + }) + }) + .collect::>()?; + + Ok(VoteListResponse { votes }) +} + +fn query_voter(deps: Deps, voter: String) -> StdResult { + let cfg = CONFIG.load(deps.storage)?; + let voter_addr = deps.api.addr_validate(&voter)?; + let weight = cfg.group_addr.is_member(&deps.querier, &voter_addr, None)?; + + Ok(VoterResponse { weight }) +} + +fn list_voters( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let cfg = CONFIG.load(deps.storage)?; + let voters = cfg + .group_addr + .list_members(&deps.querier, start_after, limit)? + .into_iter() + .map(|member| VoterDetail { + addr: member.addr, + weight: member.weight, + }) + .collect(); + Ok(VoterListResponse { voters }) +} + +#[cfg(test)] +mod tests { + use cosmwasm_std::{coin, coins, Addr, BankMsg, Coin, Decimal, Timestamp}; + + use cw2::{query_contract_info, ContractVersion}; + use cw4::{Cw4ExecuteMsg, Member}; + use cw4_group::helpers::Cw4GroupContract; + use cw_multi_test::{next_block, App, AppBuilder, Contract, ContractWrapper, Executor}; + use cw_utils::{Duration, Threshold}; + + use super::*; + + const OWNER: &str = "admin0001"; + const VOTER1: &str = "voter0001"; + const VOTER2: &str = "voter0002"; + const VOTER3: &str = "voter0003"; + const VOTER4: &str = "voter0004"; + const VOTER5: &str = "voter0005"; + const SOMEBODY: &str = "somebody"; + + fn member>(addr: T, weight: u64) -> Member { + Member { + addr: addr.into(), + weight, + } + } + + pub fn contract_flex() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ); + Box::new(contract) + } + + pub fn contract_group() -> Box> { + let contract = ContractWrapper::new( + cw4_group::contract::execute, + cw4_group::contract::instantiate, + cw4_group::contract::query, + ); + Box::new(contract) + } + + fn mock_app(init_funds: &[Coin]) -> App { + AppBuilder::new().build(|router, _, storage| { + router + .bank + .init_balance(storage, &Addr::unchecked(OWNER), init_funds.to_vec()) + .unwrap(); + }) + } + + // uploads code and returns address of group contract + fn instantiate_group(app: &mut App, members: Vec) -> Addr { + let group_id = app.store_code(contract_group()); + let msg = cw4_group::msg::InstantiateMsg { + admin: Some(OWNER.into()), + members, + }; + app.instantiate_contract(group_id, Addr::unchecked(OWNER), &msg, &[], "group", None) + .unwrap() + } + + #[track_caller] + fn instantiate_flex( + app: &mut App, + group: Addr, + threshold: Threshold, + max_voting_period: Duration, + ) -> Addr { + let flex_id = app.store_code(contract_flex()); + let msg = crate::msg::InstantiateMsg { + group_addr: group.to_string(), + threshold, + max_voting_period, + }; + app.instantiate_contract(flex_id, Addr::unchecked(OWNER), &msg, &[], "flex", None) + .unwrap() + } + + // this will set up both contracts, instantiating the group with + // all voters defined above, and the multisig pointing to it and given threshold criteria. + // Returns (multisig address, group address). + #[track_caller] + fn setup_test_case_fixed( + app: &mut App, + weight_needed: u64, + max_voting_period: Duration, + init_funds: Vec, + multisig_as_group_admin: bool, + ) -> (Addr, Addr) { + setup_test_case( + app, + Threshold::AbsoluteCount { + weight: weight_needed, + }, + max_voting_period, + init_funds, + multisig_as_group_admin, + ) + } + + #[track_caller] + fn setup_test_case( + app: &mut App, + threshold: Threshold, + max_voting_period: Duration, + init_funds: Vec, + multisig_as_group_admin: bool, + ) -> (Addr, Addr) { + // 1. Instantiate group contract with members (and OWNER as admin) + let members = vec![ + member(OWNER, 0), + member(VOTER1, 1), + member(VOTER2, 2), + member(VOTER3, 3), + member(VOTER4, 12), // so that he alone can pass a 50 / 52% threshold proposal + member(VOTER5, 5), + ]; + let group_addr = instantiate_group(app, members); + app.update_block(next_block); + + // 2. Set up Multisig backed by this group + let flex_addr = instantiate_flex(app, group_addr.clone(), threshold, max_voting_period); + app.update_block(next_block); + + // 3. (Optional) Set the multisig as the group owner + if multisig_as_group_admin { + let update_admin = Cw4ExecuteMsg::UpdateAdmin { + admin: Some(flex_addr.to_string()), + }; + app.execute_contract( + Addr::unchecked(OWNER), + group_addr.clone(), + &update_admin, + &[], + ) + .unwrap(); + app.update_block(next_block); + } + + // Bonus: set some funds on the multisig contract for future proposals + if !init_funds.is_empty() { + app.send_tokens(Addr::unchecked(OWNER), flex_addr.clone(), &init_funds) + .unwrap(); + } + (flex_addr, group_addr) + } + + fn proposal_info() -> (Vec>, String, String) { + let bank_msg = BankMsg::Send { + to_address: SOMEBODY.into(), + amount: coins(1, "BTC"), + }; + let msgs = vec![bank_msg.into()]; + let title = "Pay somebody".to_string(); + let description = "Do I pay her?".to_string(); + (msgs, title, description) + } + + fn pay_somebody_proposal() -> ExecuteMsg { + let (msgs, title, description) = proposal_info(); + ExecuteMsg::Propose { + title, + description, + msgs, + latest: None, + } + } + + #[test] + fn test_instantiate_works() { + let mut app = mock_app(&[]); + + // make a simple group + let group_addr = instantiate_group(&mut app, vec![member(OWNER, 1)]); + let flex_id = app.store_code(contract_flex()); + + let max_voting_period = Duration::Time(1234567); + + // Zero required weight fails + let instantiate_msg = InstantiateMsg { + group_addr: group_addr.to_string(), + threshold: Threshold::ThresholdQuorum { + threshold: Decimal::zero(), + quorum: Decimal::percent(1), + }, + max_voting_period, + }; + let err = app + .instantiate_contract( + flex_id, + Addr::unchecked(OWNER), + &instantiate_msg, + &[], + "zero required weight", + None, + ) + .unwrap_err(); + assert_eq!( + ContractError::Threshold(cw_utils::ThresholdError::InvalidThreshold {}), + err.downcast().unwrap() + ); + + // Total weight less than required weight not allowed + let instantiate_msg = InstantiateMsg { + group_addr: group_addr.to_string(), + threshold: Threshold::AbsoluteCount { weight: 100 }, + max_voting_period, + }; + let err = app + .instantiate_contract( + flex_id, + Addr::unchecked(OWNER), + &instantiate_msg, + &[], + "high required weight", + None, + ) + .unwrap_err(); + assert_eq!( + ContractError::Threshold(cw_utils::ThresholdError::UnreachableWeight {}), + err.downcast().unwrap() + ); + + // All valid + let instantiate_msg = InstantiateMsg { + group_addr: group_addr.to_string(), + threshold: Threshold::AbsoluteCount { weight: 1 }, + max_voting_period, + }; + let flex_addr = app + .instantiate_contract( + flex_id, + Addr::unchecked(OWNER), + &instantiate_msg, + &[], + "all good", + None, + ) + .unwrap(); + + // Verify contract version set properly + let version = query_contract_info(&app, flex_addr.clone()).unwrap(); + assert_eq!( + ContractVersion { + contract: CONTRACT_NAME.to_string(), + version: CONTRACT_VERSION.to_string(), + }, + version, + ); + + // Get voters query + let voters: VoterListResponse = app + .wrap() + .query_wasm_smart( + &flex_addr, + &QueryMsg::ListVoters { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!( + voters.voters, + vec![VoterDetail { + addr: OWNER.into(), + weight: 1 + }] + ); + } + + #[test] + fn test_propose_works() { + let init_funds = coins(10, "BTC"); + let mut app = mock_app(&init_funds); + + let required_weight = 4; + let voting_period = Duration::Time(2000000); + let (flex_addr, _) = + setup_test_case_fixed(&mut app, required_weight, voting_period, init_funds, false); + + let proposal = pay_somebody_proposal(); + // Only voters can propose + let err = app + .execute_contract(Addr::unchecked(SOMEBODY), flex_addr.clone(), &proposal, &[]) + .unwrap_err(); + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); + + // Wrong expiration option fails + let msgs = match proposal.clone() { + ExecuteMsg::Propose { msgs, .. } => msgs, + _ => panic!("Wrong variant"), + }; + let proposal_wrong_exp = ExecuteMsg::Propose { + title: "Rewarding somebody".to_string(), + description: "Do we reward her?".to_string(), + msgs, + latest: Some(Expiration::AtHeight(123456)), + }; + let err = app + .execute_contract( + Addr::unchecked(OWNER), + flex_addr.clone(), + &proposal_wrong_exp, + &[], + ) + .unwrap_err(); + assert_eq!(ContractError::WrongExpiration {}, err.downcast().unwrap()); + + // Proposal from voter works + let res = app + .execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &proposal, &[]) + .unwrap(); + assert_eq!( + res.custom_attrs(1), + [ + ("action", "propose"), + ("sender", VOTER3), + ("proposal_id", "1"), + ("status", "Open"), + ], + ); + + // Proposal from voter with enough vote power directly passes + let res = app + .execute_contract(Addr::unchecked(VOTER4), flex_addr, &proposal, &[]) + .unwrap(); + assert_eq!( + res.custom_attrs(1), + [ + ("action", "propose"), + ("sender", VOTER4), + ("proposal_id", "2"), + ("status", "Passed"), + ], + ); + } + + fn get_tally(app: &App, flex_addr: &str, proposal_id: u64) -> u64 { + // Get all the voters on the proposal + let voters = QueryMsg::ListVotes { + proposal_id, + start_after: None, + limit: None, + }; + let votes: VoteListResponse = app.wrap().query_wasm_smart(flex_addr, &voters).unwrap(); + // Sum the weights of the Yes votes to get the tally + votes + .votes + .iter() + .filter(|&v| v.vote == Vote::Yes) + .map(|v| v.weight) + .sum() + } + + fn expire(voting_period: Duration) -> impl Fn(&mut BlockInfo) { + move |block: &mut BlockInfo| { + match voting_period { + Duration::Time(duration) => block.time = block.time.plus_seconds(duration + 1), + Duration::Height(duration) => block.height += duration + 1, + }; + } + } + + fn unexpire(voting_period: Duration) -> impl Fn(&mut BlockInfo) { + move |block: &mut BlockInfo| { + match voting_period { + Duration::Time(duration) => { + block.time = + Timestamp::from_nanos(block.time.nanos() - (duration * 1_000_000_000)); + } + Duration::Height(duration) => block.height -= duration, + }; + } + } + + #[test] + fn test_proposal_queries() { + let init_funds = coins(10, "BTC"); + let mut app = mock_app(&init_funds); + + let voting_period = Duration::Time(2000000); + let threshold = Threshold::ThresholdQuorum { + threshold: Decimal::percent(80), + quorum: Decimal::percent(20), + }; + let (flex_addr, _) = setup_test_case(&mut app, threshold, voting_period, init_funds, false); + + // create proposal with 1 vote power + let proposal = pay_somebody_proposal(); + let res = app + .execute_contract(Addr::unchecked(VOTER1), flex_addr.clone(), &proposal, &[]) + .unwrap(); + let proposal_id1: u64 = res.custom_attrs(1)[2].value.parse().unwrap(); + + // another proposal immediately passes + app.update_block(next_block); + let proposal = pay_somebody_proposal(); + let res = app + .execute_contract(Addr::unchecked(VOTER4), flex_addr.clone(), &proposal, &[]) + .unwrap(); + let proposal_id2: u64 = res.custom_attrs(1)[2].value.parse().unwrap(); + + // expire them both + app.update_block(expire(voting_period)); + + // add one more open proposal, 2 votes + let proposal = pay_somebody_proposal(); + let res = app + .execute_contract(Addr::unchecked(VOTER2), flex_addr.clone(), &proposal, &[]) + .unwrap(); + let proposal_id3: u64 = res.custom_attrs(1)[2].value.parse().unwrap(); + let proposed_at = app.block_info(); + + // next block, let's query them all... make sure status is properly updated (1 should be rejected in query) + app.update_block(next_block); + let list_query = QueryMsg::ListProposals { + start_after: None, + limit: None, + }; + let res: ProposalListResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &list_query) + .unwrap(); + assert_eq!(3, res.proposals.len()); + + // check the id and status are properly set + let info: Vec<_> = res.proposals.iter().map(|p| (p.id, p.status)).collect(); + let expected_info = vec![ + (proposal_id1, Status::Rejected), + (proposal_id2, Status::Passed), + (proposal_id3, Status::Open), + ]; + assert_eq!(expected_info, info); + + // ensure the common features are set + let (expected_msgs, expected_title, expected_description) = proposal_info(); + for prop in res.proposals { + assert_eq!(prop.title, expected_title); + assert_eq!(prop.description, expected_description); + assert_eq!(prop.msgs, expected_msgs); + } + + // reverse query can get just proposal_id3 + let list_query = QueryMsg::ReverseProposals { + start_before: None, + limit: Some(1), + }; + let res: ProposalListResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &list_query) + .unwrap(); + assert_eq!(1, res.proposals.len()); + + let (msgs, title, description) = proposal_info(); + let expected = ProposalResponse { + id: proposal_id3, + title, + description, + msgs, + expires: voting_period.after(&proposed_at), + status: Status::Open, + threshold: ThresholdResponse::ThresholdQuorum { + total_weight: 23, + threshold: Decimal::percent(80), + quorum: Decimal::percent(20), + }, + }; + assert_eq!(&expected, &res.proposals[0]); + } + + #[test] + fn test_vote_works() { + let init_funds = coins(10, "BTC"); + let mut app = mock_app(&init_funds); + + let threshold = Threshold::ThresholdQuorum { + threshold: Decimal::percent(51), + quorum: Decimal::percent(1), + }; + let voting_period = Duration::Time(2000000); + let (flex_addr, _) = setup_test_case(&mut app, threshold, voting_period, init_funds, false); + + // create proposal with 0 vote power + let proposal = pay_somebody_proposal(); + let res = app + .execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &proposal, &[]) + .unwrap(); + + // Get the proposal id from the logs + let proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap(); + + // Owner with 0 voting power cannot vote + let yes_vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::Yes, + }; + let err = app + .execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &yes_vote, &[]) + .unwrap_err(); + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); + + // Only voters can vote + let err = app + .execute_contract(Addr::unchecked(SOMEBODY), flex_addr.clone(), &yes_vote, &[]) + .unwrap_err(); + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); + + // But voter1 can + let res = app + .execute_contract(Addr::unchecked(VOTER1), flex_addr.clone(), &yes_vote, &[]) + .unwrap(); + assert_eq!( + res.custom_attrs(1), + [ + ("action", "vote"), + ("sender", VOTER1), + ("proposal_id", proposal_id.to_string().as_str()), + ("status", "Open"), + ], + ); + + // VOTER1 cannot vote again + let err = app + .execute_contract(Addr::unchecked(VOTER1), flex_addr.clone(), &yes_vote, &[]) + .unwrap_err(); + assert_eq!(ContractError::AlreadyVoted {}, err.downcast().unwrap()); + + // No/Veto votes have no effect on the tally + // Compute the current tally + let tally = get_tally(&app, flex_addr.as_ref(), proposal_id); + assert_eq!(tally, 1); + + // Cast a No vote + let no_vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::No, + }; + let _ = app + .execute_contract(Addr::unchecked(VOTER2), flex_addr.clone(), &no_vote, &[]) + .unwrap(); + + // Cast a Veto vote + let veto_vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::Veto, + }; + let _ = app + .execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &veto_vote, &[]) + .unwrap(); + + // Tally unchanged + assert_eq!(tally, get_tally(&app, flex_addr.as_ref(), proposal_id)); + + let err = app + .execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &yes_vote, &[]) + .unwrap_err(); + assert_eq!(ContractError::AlreadyVoted {}, err.downcast().unwrap()); + + // Expired proposals cannot be voted + app.update_block(expire(voting_period)); + let err = app + .execute_contract(Addr::unchecked(VOTER4), flex_addr.clone(), &yes_vote, &[]) + .unwrap_err(); + assert_eq!(ContractError::Expired {}, err.downcast().unwrap()); + app.update_block(unexpire(voting_period)); + + // Powerful voter supports it, so it passes + let res = app + .execute_contract(Addr::unchecked(VOTER4), flex_addr.clone(), &yes_vote, &[]) + .unwrap(); + assert_eq!( + res.custom_attrs(1), + [ + ("action", "vote"), + ("sender", VOTER4), + ("proposal_id", proposal_id.to_string().as_str()), + ("status", "Passed"), + ], + ); + + // non-Open proposals cannot be voted + let err = app + .execute_contract(Addr::unchecked(VOTER5), flex_addr.clone(), &yes_vote, &[]) + .unwrap_err(); + assert_eq!(ContractError::NotOpen {}, err.downcast().unwrap()); + + // query individual votes + // initial (with 0 weight) + let voter = OWNER.into(); + let vote: VoteResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &QueryMsg::Vote { proposal_id, voter }) + .unwrap(); + assert_eq!( + vote.vote.unwrap(), + VoteInfo { + proposal_id, + voter: OWNER.into(), + vote: Vote::Yes, + weight: 0 + } + ); + + // nay sayer + let voter = VOTER2.into(); + let vote: VoteResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &QueryMsg::Vote { proposal_id, voter }) + .unwrap(); + assert_eq!( + vote.vote.unwrap(), + VoteInfo { + proposal_id, + voter: VOTER2.into(), + vote: Vote::No, + weight: 2 + } + ); + + // non-voter + let voter = VOTER5.into(); + let vote: VoteResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &QueryMsg::Vote { proposal_id, voter }) + .unwrap(); + assert!(vote.vote.is_none()); + + // create proposal with 0 vote power + let proposal = pay_somebody_proposal(); + let res = app + .execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &proposal, &[]) + .unwrap(); + + // Get the proposal id from the logs + let proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap(); + + // Cast a No vote + let no_vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::No, + }; + let _ = app + .execute_contract(Addr::unchecked(VOTER2), flex_addr.clone(), &no_vote, &[]) + .unwrap(); + + // Powerful voter opposes it, so it rejects + let res = app + .execute_contract(Addr::unchecked(VOTER4), flex_addr, &no_vote, &[]) + .unwrap(); + + assert_eq!( + res.custom_attrs(1), + [ + ("action", "vote"), + ("sender", VOTER4), + ("proposal_id", proposal_id.to_string().as_str()), + ("status", "Rejected"), + ], + ); + } + + #[test] + fn test_execute_works() { + let init_funds = coins(10, "BTC"); + let mut app = mock_app(&init_funds); + + let threshold = Threshold::ThresholdQuorum { + threshold: Decimal::percent(51), + quorum: Decimal::percent(1), + }; + let voting_period = Duration::Time(2000000); + let (flex_addr, _) = setup_test_case(&mut app, threshold, voting_period, init_funds, true); + + // ensure we have cash to cover the proposal + let contract_bal = app.wrap().query_balance(&flex_addr, "BTC").unwrap(); + assert_eq!(contract_bal, coin(10, "BTC")); + + // create proposal with 0 vote power + let proposal = pay_somebody_proposal(); + let res = app + .execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &proposal, &[]) + .unwrap(); + + // Get the proposal id from the logs + let proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap(); + + // Only Passed can be executed + let execution = ExecuteMsg::Execute { proposal_id }; + let err = app + .execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &execution, &[]) + .unwrap_err(); + assert_eq!( + ContractError::WrongExecuteStatus {}, + err.downcast().unwrap() + ); + + // Vote it, so it passes + let vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::Yes, + }; + let res = app + .execute_contract(Addr::unchecked(VOTER4), flex_addr.clone(), &vote, &[]) + .unwrap(); + assert_eq!( + res.custom_attrs(1), + [ + ("action", "vote"), + ("sender", VOTER4), + ("proposal_id", proposal_id.to_string().as_str()), + ("status", "Passed"), + ], + ); + + // In passing: Try to close Passed fails + let closing = ExecuteMsg::Close { proposal_id }; + let err = app + .execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &closing, &[]) + .unwrap_err(); + assert_eq!(ContractError::WrongCloseStatus {}, err.downcast().unwrap()); + + // Execute works. Anybody can execute Passed proposals + let res = app + .execute_contract( + Addr::unchecked(SOMEBODY), + flex_addr.clone(), + &execution, + &[], + ) + .unwrap(); + assert_eq!( + res.custom_attrs(1), + [ + ("action", "execute"), + ("sender", SOMEBODY), + ("proposal_id", proposal_id.to_string().as_str()), + ], + ); + + // verify money was transfered + let some_bal = app.wrap().query_balance(SOMEBODY, "BTC").unwrap(); + assert_eq!(some_bal, coin(1, "BTC")); + let contract_bal = app.wrap().query_balance(&flex_addr, "BTC").unwrap(); + assert_eq!(contract_bal, coin(9, "BTC")); + + // In passing: Try to close Executed fails + let err = app + .execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &closing, &[]) + .unwrap_err(); + assert_eq!(ContractError::WrongCloseStatus {}, err.downcast().unwrap()); + + // Trying to execute something that was already executed fails + let err = app + .execute_contract(Addr::unchecked(SOMEBODY), flex_addr, &execution, &[]) + .unwrap_err(); + assert_eq!( + ContractError::WrongExecuteStatus {}, + err.downcast().unwrap() + ); + } + + #[test] + fn proposal_pass_on_expiration() { + let init_funds = coins(10, "BTC"); + let mut app = mock_app(&init_funds); + + let threshold = Threshold::ThresholdQuorum { + threshold: Decimal::percent(51), + quorum: Decimal::percent(1), + }; + let voting_period = 2000000; + let (flex_addr, _) = setup_test_case( + &mut app, + threshold, + Duration::Time(voting_period), + init_funds, + true, + ); + + // ensure we have cash to cover the proposal + let contract_bal = app.wrap().query_balance(&flex_addr, "BTC").unwrap(); + assert_eq!(contract_bal, coin(10, "BTC")); + + // create proposal with 0 vote power + let proposal = pay_somebody_proposal(); + let res = app + .execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &proposal, &[]) + .unwrap(); + + // Get the proposal id from the logs + let proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap(); + + // Vote it, so it passes after voting period is over + let vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::Yes, + }; + let res = app + .execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &vote, &[]) + .unwrap(); + assert_eq!( + res.custom_attrs(1), + [ + ("action", "vote"), + ("sender", VOTER3), + ("proposal_id", proposal_id.to_string().as_str()), + ("status", "Open"), + ], + ); + + // Wait until the voting period is over. + app.update_block(|block| { + block.time = block.time.plus_seconds(voting_period); + block.height += std::cmp::max(1, voting_period / 5); + }); + + // Proposal should now be passed. + let prop: ProposalResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &QueryMsg::Proposal { proposal_id }) + .unwrap(); + assert_eq!(prop.status, Status::Passed); + + // Execution should now be possible. + let res = app + .execute_contract( + Addr::unchecked(SOMEBODY), + flex_addr, + &ExecuteMsg::Execute { proposal_id }, + &[], + ) + .unwrap(); + assert_eq!( + res.custom_attrs(1), + [ + ("action", "execute"), + ("sender", SOMEBODY), + ("proposal_id", proposal_id.to_string().as_str()), + ], + ); + } + + #[test] + fn test_close_works() { + let init_funds = coins(10, "BTC"); + let mut app = mock_app(&init_funds); + + let threshold = Threshold::ThresholdQuorum { + threshold: Decimal::percent(51), + quorum: Decimal::percent(1), + }; + let voting_period = Duration::Height(2000000); + let (flex_addr, _) = setup_test_case(&mut app, threshold, voting_period, init_funds, true); + + // create proposal with 0 vote power + let proposal = pay_somebody_proposal(); + let res = app + .execute_contract(Addr::unchecked(OWNER), flex_addr.clone(), &proposal, &[]) + .unwrap(); + + // Get the proposal id from the logs + let proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap(); + + // Non-expired proposals cannot be closed + let closing = ExecuteMsg::Close { proposal_id }; + let err = app + .execute_contract(Addr::unchecked(SOMEBODY), flex_addr.clone(), &closing, &[]) + .unwrap_err(); + assert_eq!(ContractError::NotExpired {}, err.downcast().unwrap()); + + // Expired proposals can be closed + app.update_block(expire(voting_period)); + let res = app + .execute_contract(Addr::unchecked(SOMEBODY), flex_addr.clone(), &closing, &[]) + .unwrap(); + assert_eq!( + res.custom_attrs(1), + [ + ("action", "close"), + ("sender", SOMEBODY), + ("proposal_id", proposal_id.to_string().as_str()), + ], + ); + + // Trying to close it again fails + let closing = ExecuteMsg::Close { proposal_id }; + let err = app + .execute_contract(Addr::unchecked(SOMEBODY), flex_addr, &closing, &[]) + .unwrap_err(); + assert_eq!(ContractError::WrongCloseStatus {}, err.downcast().unwrap()); + } + + // uses the power from the beginning of the voting period + #[test] + fn execute_group_changes_from_external() { + let init_funds = coins(10, "BTC"); + let mut app = mock_app(&init_funds); + + let threshold = Threshold::ThresholdQuorum { + threshold: Decimal::percent(51), + quorum: Decimal::percent(1), + }; + let voting_period = Duration::Time(20000); + let (flex_addr, group_addr) = + setup_test_case(&mut app, threshold, voting_period, init_funds, false); + + // VOTER1 starts a proposal to send some tokens (1/4 votes) + let proposal = pay_somebody_proposal(); + let res = app + .execute_contract(Addr::unchecked(VOTER1), flex_addr.clone(), &proposal, &[]) + .unwrap(); + // Get the proposal id from the logs + let proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap(); + let prop_status = |app: &App, proposal_id: u64| -> Status { + let query_prop = QueryMsg::Proposal { proposal_id }; + let prop: ProposalResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &query_prop) + .unwrap(); + prop.status + }; + + // 1/4 votes + assert_eq!(prop_status(&app, proposal_id), Status::Open); + + // check current threshold (global) + let threshold: ThresholdResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &QueryMsg::Threshold {}) + .unwrap(); + let expected_thresh = ThresholdResponse::ThresholdQuorum { + total_weight: 23, + threshold: Decimal::percent(51), + quorum: Decimal::percent(1), + }; + assert_eq!(expected_thresh, threshold); + + // a few blocks later... + app.update_block(|block| block.height += 2); + + // admin changes the group + // updates VOTER2 power to 21 -> with snapshot, vote doesn't pass proposal + // adds NEWBIE with 2 power -> with snapshot, invalid vote + // removes VOTER3 -> with snapshot, can vote on proposal + let newbie: &str = "newbie"; + let update_msg = cw4_group::msg::ExecuteMsg::UpdateMembers { + remove: vec![VOTER3.into()], + add: vec![member(VOTER2, 21), member(newbie, 2)], + }; + app.execute_contract(Addr::unchecked(OWNER), group_addr, &update_msg, &[]) + .unwrap(); + + // check membership queries properly updated + let query_voter = QueryMsg::Voter { + address: VOTER3.into(), + }; + let power: VoterResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &query_voter) + .unwrap(); + assert_eq!(power.weight, None); + + // proposal still open + assert_eq!(prop_status(&app, proposal_id), Status::Open); + + // a few blocks later... + app.update_block(|block| block.height += 3); + + // make a second proposal + let proposal2 = pay_somebody_proposal(); + let res = app + .execute_contract(Addr::unchecked(VOTER1), flex_addr.clone(), &proposal2, &[]) + .unwrap(); + // Get the proposal id from the logs + let proposal_id2: u64 = res.custom_attrs(1)[2].value.parse().unwrap(); + + // VOTER2 can pass this alone with the updated vote (newer height ignores snapshot) + let yes_vote = ExecuteMsg::Vote { + proposal_id: proposal_id2, + vote: Vote::Yes, + }; + app.execute_contract(Addr::unchecked(VOTER2), flex_addr.clone(), &yes_vote, &[]) + .unwrap(); + assert_eq!(prop_status(&app, proposal_id2), Status::Passed); + + // VOTER2 can only vote on first proposal with weight of 2 (not enough to pass) + let yes_vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::Yes, + }; + app.execute_contract(Addr::unchecked(VOTER2), flex_addr.clone(), &yes_vote, &[]) + .unwrap(); + assert_eq!(prop_status(&app, proposal_id), Status::Open); + + // newbie cannot vote + let err = app + .execute_contract(Addr::unchecked(newbie), flex_addr.clone(), &yes_vote, &[]) + .unwrap_err(); + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); + + // previously removed VOTER3 can still vote, passing the proposal + app.execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &yes_vote, &[]) + .unwrap(); + + // check current threshold (global) is updated + let threshold: ThresholdResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &QueryMsg::Threshold {}) + .unwrap(); + let expected_thresh = ThresholdResponse::ThresholdQuorum { + total_weight: 41, + threshold: Decimal::percent(51), + quorum: Decimal::percent(1), + }; + assert_eq!(expected_thresh, threshold); + + // TODO: check proposal threshold not changed + } + + // uses the power from the beginning of the voting period + // similar to above - simpler case, but shows that one proposals can + // trigger the action + #[test] + fn execute_group_changes_from_proposal() { + let init_funds = coins(10, "BTC"); + let mut app = mock_app(&init_funds); + + let required_weight = 4; + let voting_period = Duration::Time(20000); + let (flex_addr, group_addr) = + setup_test_case_fixed(&mut app, required_weight, voting_period, init_funds, true); + + // Start a proposal to remove VOTER3 from the set + let update_msg = Cw4GroupContract::new(group_addr) + .update_members(vec![VOTER3.into()], vec![]) + .unwrap(); + let update_proposal = ExecuteMsg::Propose { + title: "Kick out VOTER3".to_string(), + description: "He's trying to steal our money".to_string(), + msgs: vec![update_msg], + latest: None, + }; + let res = app + .execute_contract( + Addr::unchecked(VOTER1), + flex_addr.clone(), + &update_proposal, + &[], + ) + .unwrap(); + // Get the proposal id from the logs + let update_proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap(); + + // next block... + app.update_block(|b| b.height += 1); + + // VOTER1 starts a proposal to send some tokens + let cash_proposal = pay_somebody_proposal(); + let res = app + .execute_contract( + Addr::unchecked(VOTER1), + flex_addr.clone(), + &cash_proposal, + &[], + ) + .unwrap(); + // Get the proposal id from the logs + let cash_proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap(); + assert_ne!(cash_proposal_id, update_proposal_id); + + // query proposal state + let prop_status = |app: &App, proposal_id: u64| -> Status { + let query_prop = QueryMsg::Proposal { proposal_id }; + let prop: ProposalResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &query_prop) + .unwrap(); + prop.status + }; + assert_eq!(prop_status(&app, cash_proposal_id), Status::Open); + assert_eq!(prop_status(&app, update_proposal_id), Status::Open); + + // next block... + app.update_block(|b| b.height += 1); + + // Pass and execute first proposal + let yes_vote = ExecuteMsg::Vote { + proposal_id: update_proposal_id, + vote: Vote::Yes, + }; + app.execute_contract(Addr::unchecked(VOTER4), flex_addr.clone(), &yes_vote, &[]) + .unwrap(); + let execution = ExecuteMsg::Execute { + proposal_id: update_proposal_id, + }; + app.execute_contract(Addr::unchecked(VOTER4), flex_addr.clone(), &execution, &[]) + .unwrap(); + + // ensure that the update_proposal is executed, but the other unchanged + assert_eq!(prop_status(&app, update_proposal_id), Status::Executed); + assert_eq!(prop_status(&app, cash_proposal_id), Status::Open); + + // next block... + app.update_block(|b| b.height += 1); + + // VOTER3 can still pass the cash proposal + // voting on it fails + let yes_vote = ExecuteMsg::Vote { + proposal_id: cash_proposal_id, + vote: Vote::Yes, + }; + app.execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &yes_vote, &[]) + .unwrap(); + assert_eq!(prop_status(&app, cash_proposal_id), Status::Passed); + + // but cannot open a new one + let cash_proposal = pay_somebody_proposal(); + let err = app + .execute_contract( + Addr::unchecked(VOTER3), + flex_addr.clone(), + &cash_proposal, + &[], + ) + .unwrap_err(); + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); + + // extra: ensure no one else can call the hook + let hook_hack = ExecuteMsg::MemberChangedHook(MemberChangedHookMsg { + diffs: vec![MemberDiff::new(VOTER1, Some(1), None)], + }); + let err = app + .execute_contract(Addr::unchecked(VOTER2), flex_addr.clone(), &hook_hack, &[]) + .unwrap_err(); + assert_eq!(ContractError::Unauthorized {}, err.downcast().unwrap()); + } + + // uses the power from the beginning of the voting period + #[test] + fn percentage_handles_group_changes() { + let init_funds = coins(10, "BTC"); + let mut app = mock_app(&init_funds); + + // 51% required, which is 12 of the initial 24 + let threshold = Threshold::ThresholdQuorum { + threshold: Decimal::percent(51), + quorum: Decimal::percent(1), + }; + let voting_period = Duration::Time(20000); + let (flex_addr, group_addr) = + setup_test_case(&mut app, threshold, voting_period, init_funds, false); + + // VOTER3 starts a proposal to send some tokens (3/12 votes) + let proposal = pay_somebody_proposal(); + let res = app + .execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &proposal, &[]) + .unwrap(); + // Get the proposal id from the logs + let proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap(); + let prop_status = |app: &App| -> Status { + let query_prop = QueryMsg::Proposal { proposal_id }; + let prop: ProposalResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &query_prop) + .unwrap(); + prop.status + }; + + // 3/12 votes + assert_eq!(prop_status(&app), Status::Open); + + // a few blocks later... + app.update_block(|block| block.height += 2); + + // admin changes the group (3 -> 0, 2 -> 9, 0 -> 29) - total = 56, require 29 to pass + let newbie: &str = "newbie"; + let update_msg = cw4_group::msg::ExecuteMsg::UpdateMembers { + remove: vec![VOTER3.into()], + add: vec![member(VOTER2, 9), member(newbie, 29)], + }; + app.execute_contract(Addr::unchecked(OWNER), group_addr, &update_msg, &[]) + .unwrap(); + + // a few blocks later... + app.update_block(|block| block.height += 3); + + // VOTER2 votes according to original weights: 3 + 2 = 5 / 12 => Open + // with updated weights, it would be 3 + 9 = 12 / 12 => Passed + let yes_vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::Yes, + }; + app.execute_contract(Addr::unchecked(VOTER2), flex_addr.clone(), &yes_vote, &[]) + .unwrap(); + assert_eq!(prop_status(&app), Status::Open); + + // new proposal can be passed single-handedly by newbie + let proposal = pay_somebody_proposal(); + let res = app + .execute_contract(Addr::unchecked(newbie), flex_addr.clone(), &proposal, &[]) + .unwrap(); + // Get the proposal id from the logs + let proposal_id2: u64 = res.custom_attrs(1)[2].value.parse().unwrap(); + + // check proposal2 status + let query_prop = QueryMsg::Proposal { + proposal_id: proposal_id2, + }; + let prop: ProposalResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &query_prop) + .unwrap(); + assert_eq!(Status::Passed, prop.status); + } + + // uses the power from the beginning of the voting period + #[test] + fn quorum_handles_group_changes() { + let init_funds = coins(10, "BTC"); + let mut app = mock_app(&init_funds); + + // 33% required for quora, which is 8 of the initial 24 + // 50% yes required to pass early (12 of the initial 24) + let voting_period = Duration::Time(20000); + let (flex_addr, group_addr) = setup_test_case( + &mut app, + Threshold::ThresholdQuorum { + threshold: Decimal::percent(51), + quorum: Decimal::percent(33), + }, + voting_period, + init_funds, + false, + ); + + // VOTER3 starts a proposal to send some tokens (3 votes) + let proposal = pay_somebody_proposal(); + let res = app + .execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &proposal, &[]) + .unwrap(); + // Get the proposal id from the logs + let proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap(); + let prop_status = |app: &App| -> Status { + let query_prop = QueryMsg::Proposal { proposal_id }; + let prop: ProposalResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &query_prop) + .unwrap(); + prop.status + }; + + // 3/12 votes - not expired + assert_eq!(prop_status(&app), Status::Open); + + // a few blocks later... + app.update_block(|block| block.height += 2); + + // admin changes the group (3 -> 0, 2 -> 9, 0 -> 28) - total = 55, require 28 to pass + let newbie: &str = "newbie"; + let update_msg = cw4_group::msg::ExecuteMsg::UpdateMembers { + remove: vec![VOTER3.into()], + add: vec![member(VOTER2, 9), member(newbie, 29)], + }; + app.execute_contract(Addr::unchecked(OWNER), group_addr, &update_msg, &[]) + .unwrap(); + + // a few blocks later... + app.update_block(|block| block.height += 3); + + // VOTER2 votes yes, according to original weights: 3 yes, 2 no, 5 total (will fail when expired) + // with updated weights, it would be 3 yes, 9 yes, 11 total (will pass when expired) + let yes_vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::Yes, + }; + app.execute_contract(Addr::unchecked(VOTER2), flex_addr.clone(), &yes_vote, &[]) + .unwrap(); + // not expired yet + assert_eq!(prop_status(&app), Status::Open); + + // wait until the vote is over, and see it was rejected + app.update_block(expire(voting_period)); + assert_eq!(prop_status(&app), Status::Rejected); + } + + #[test] + fn quorum_enforced_even_if_absolute_threshold_met() { + let init_funds = coins(10, "BTC"); + let mut app = mock_app(&init_funds); + + // 33% required for quora, which is 5 of the initial 15 + // 50% yes required to pass early (8 of the initial 15) + let voting_period = Duration::Time(20000); + let (flex_addr, _) = setup_test_case( + &mut app, + // note that 60% yes is not enough to pass without 20% no as well + Threshold::ThresholdQuorum { + threshold: Decimal::percent(60), + quorum: Decimal::percent(80), + }, + voting_period, + init_funds, + false, + ); + + // create proposal + let proposal = pay_somebody_proposal(); + let res = app + .execute_contract(Addr::unchecked(VOTER5), flex_addr.clone(), &proposal, &[]) + .unwrap(); + // Get the proposal id from the logs + let proposal_id: u64 = res.custom_attrs(1)[2].value.parse().unwrap(); + let prop_status = |app: &App| -> Status { + let query_prop = QueryMsg::Proposal { proposal_id }; + let prop: ProposalResponse = app + .wrap() + .query_wasm_smart(&flex_addr, &query_prop) + .unwrap(); + prop.status + }; + assert_eq!(prop_status(&app), Status::Open); + app.update_block(|block| block.height += 3); + + // reach 60% of yes votes, not enough to pass early (or late) + let yes_vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::Yes, + }; + app.execute_contract(Addr::unchecked(VOTER4), flex_addr.clone(), &yes_vote, &[]) + .unwrap(); + // 9 of 15 is 60% absolute threshold, but less than 12 (80% quorum needed) + assert_eq!(prop_status(&app), Status::Open); + + // add 3 weight no vote and we hit quorum and this passes + let no_vote = ExecuteMsg::Vote { + proposal_id, + vote: Vote::No, + }; + app.execute_contract(Addr::unchecked(VOTER3), flex_addr.clone(), &no_vote, &[]) + .unwrap(); + assert_eq!(prop_status(&app), Status::Passed); + } +} diff --git a/contracts/multisig/cw3-flex-multisig/src/error.rs b/contracts/multisig/cw3-flex-multisig/src/error.rs new file mode 100644 index 0000000000..4936a8923f --- /dev/null +++ b/contracts/multisig/cw3-flex-multisig/src/error.rs @@ -0,0 +1,40 @@ +use cosmwasm_std::StdError; +use cw_utils::ThresholdError; + +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("{0}")] + Threshold(#[from] ThresholdError), + + #[error("Group contract invalid address '{addr}'")] + InvalidGroup { addr: String }, + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Proposal is not open")] + NotOpen {}, + + #[error("Proposal voting period has expired")] + Expired {}, + + #[error("Proposal must expire before you can close it")] + NotExpired {}, + + #[error("Wrong expiration option")] + WrongExpiration {}, + + #[error("Already voted on this proposal")] + AlreadyVoted {}, + + #[error("Proposal must have passed and not yet been executed")] + WrongExecuteStatus {}, + + #[error("Cannot close completed or passed proposals")] + WrongCloseStatus {}, +} diff --git a/contracts/multisig/cw3-flex-multisig/src/lib.rs b/contracts/multisig/cw3-flex-multisig/src/lib.rs new file mode 100644 index 0000000000..e5ff7237ef --- /dev/null +++ b/contracts/multisig/cw3-flex-multisig/src/lib.rs @@ -0,0 +1,6 @@ +pub mod contract; +pub mod error; +pub mod msg; +pub mod state; + +pub use crate::error::ContractError; diff --git a/contracts/multisig/cw3-flex-multisig/src/msg.rs b/contracts/multisig/cw3-flex-multisig/src/msg.rs new file mode 100644 index 0000000000..8d72cd37b0 --- /dev/null +++ b/contracts/multisig/cw3-flex-multisig/src/msg.rs @@ -0,0 +1,75 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use cosmwasm_std::{CosmosMsg, Empty}; +use cw3::Vote; +use cw4::MemberChangedHookMsg; +use cw_utils::{Duration, Expiration, Threshold}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct InstantiateMsg { + // this is the group contract that contains the member list + pub group_addr: String, + pub threshold: Threshold, + pub max_voting_period: Duration, +} + +// TODO: add some T variants? Maybe good enough as fixed Empty for now +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + Propose { + title: String, + description: String, + msgs: Vec>, + // note: we ignore API-spec'd earliest if passed, always opens immediately + latest: Option, + }, + Vote { + proposal_id: u64, + vote: Vote, + }, + Execute { + proposal_id: u64, + }, + Close { + proposal_id: u64, + }, + /// Handles update hook messages from the group contract + MemberChangedHook(MemberChangedHookMsg), +} + +// We can also add this as a cw3 extension +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub enum QueryMsg { + /// Return ThresholdResponse + Threshold {}, + /// Returns ProposalResponse + Proposal { proposal_id: u64 }, + /// Returns ProposalListResponse + ListProposals { + start_after: Option, + limit: Option, + }, + /// Returns ProposalListResponse + ReverseProposals { + start_before: Option, + limit: Option, + }, + /// Returns VoteResponse + Vote { proposal_id: u64, voter: String }, + /// Returns VoteListResponse + ListVotes { + proposal_id: u64, + start_after: Option, + limit: Option, + }, + /// Returns VoterInfo + Voter { address: String }, + /// Returns VoterListResponse + ListVoters { + start_after: Option, + limit: Option, + }, +} diff --git a/contracts/multisig/cw3-flex-multisig/src/state.rs b/contracts/multisig/cw3-flex-multisig/src/state.rs new file mode 100644 index 0000000000..023662b8bc --- /dev/null +++ b/contracts/multisig/cw3-flex-multisig/src/state.rs @@ -0,0 +1,17 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use cw4::Cw4Contract; +use cw_storage_plus::Item; +use cw_utils::{Duration, Threshold}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct Config { + pub threshold: Threshold, + pub max_voting_period: Duration, + // Total weight and voters are queried from this contract + pub group_addr: Cw4Contract, +} + +// unique items +pub const CONFIG: Item = Item::new("config"); diff --git a/contracts/multisig/cw4-group/.cargo/config b/contracts/multisig/cw4-group/.cargo/config new file mode 100644 index 0000000000..7d1a066c82 --- /dev/null +++ b/contracts/multisig/cw4-group/.cargo/config @@ -0,0 +1,5 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +wasm-debug = "build --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/multisig/cw4-group/Cargo.toml b/contracts/multisig/cw4-group/Cargo.toml new file mode 100644 index 0000000000..bdce839d85 --- /dev/null +++ b/contracts/multisig/cw4-group/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "cw4-group" +version = "0.13.1" +authors = ["Ethan Frey "] +edition = "2018" +description = "Simple cw4 implementation of group membership controlled by admin " +license = "Apache-2.0" +repository = "https://github.com/CosmWasm/cw-plus" +homepage = "https://cosmwasm.com" +documentation = "https://docs.cosmwasm.com" + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "artifacts/*", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cw-utils = { version = "0.13.1" } +cw2 = { version = "0.13.1" } +cw4 = { version = "0.13.1" } +cw-controllers = { version = "0.13.1" } +cw-storage-plus = { version = "0.13.1" } +cosmwasm-std = { version = "1.0.0-beta6" } +schemars = "0.8.1" +serde = { version = "1.0.103", default-features = false, features = ["derive"] } +thiserror = { version = "1.0.23" } + +[dev-dependencies] +cosmwasm-schema = { version = "1.0.0-beta6" } diff --git a/contracts/multisig/cw4-group/NOTICE b/contracts/multisig/cw4-group/NOTICE new file mode 100644 index 0000000000..d459f5e2e8 --- /dev/null +++ b/contracts/multisig/cw4-group/NOTICE @@ -0,0 +1,14 @@ +Cw4_group +Copyright (C) 2020 Confio OÜ + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/contracts/multisig/cw4-group/README.md b/contracts/multisig/cw4-group/README.md new file mode 100644 index 0000000000..19dd600829 --- /dev/null +++ b/contracts/multisig/cw4-group/README.md @@ -0,0 +1,51 @@ +# CW4 Group + +This is a basic implementation of the [cw4 spec](../../packages/cw4/README.md). +It fulfills all elements of the spec, including the raw query lookups, +and it designed to be used as a backing storage for +[cw3 compliant contracts](../../packages/cw3/README.md). + +It stores a set of members along with an admin, and allows the admin to +update the state. Raw queries (intended for cross-contract queries) +can check a given member address and the total weight. Smart queries (designed +for client API) can do the same, and also query the admin address as well as +paginate over all members. + +## Init + +To create it, you must pass in a list of members, as well as an optional +`admin`, if you wish it to be mutable. + +```rust +pub struct InitMsg { + pub admin: Option, + pub members: Vec, +} + +pub struct Member { + pub addr: HumanAddr, + pub weight: u64, +} +``` + +Members are defined by an address and a weight. This is transformed +and stored under their `CanonicalAddr`, in a format defined in +[cw4 raw queries](../../packages/cw4/README.md#raw). + +Note that 0 *is an allowed weight*. This doesn't give any voting rights, but +it does define this address is part of the group. This could be used in +e.g. a KYC whitelist to say they are allowed, but cannot participate in +decision-making. + +## Messages + +Basic update messages, queries, and hooks are defined by the +[cw4 spec](../../packages/cw4/README.md). Please refer to it for more info. + +`cw4-group` adds one message to control the group membership: + +`UpdateMembers{add, remove}` - takes a membership diff and adds/updates the +members, as well as removing any provided addresses. If an address is on both +lists, it will be removed. If it appears multiple times in `add`, only the +last occurrence will be used. + diff --git a/contracts/multisig/cw4-group/src/contract.rs b/contracts/multisig/cw4-group/src/contract.rs new file mode 100644 index 0000000000..913d427f6a --- /dev/null +++ b/contracts/multisig/cw4-group/src/contract.rs @@ -0,0 +1,556 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + attr, to_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Order, Response, StdResult, + SubMsg, +}; +use cw2::set_contract_version; +use cw4::{ + Member, MemberChangedHookMsg, MemberDiff, MemberListResponse, MemberResponse, + TotalWeightResponse, +}; +use cw_storage_plus::Bound; +use cw_utils::maybe_addr; + +use crate::error::ContractError; +use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use crate::state::{ADMIN, HOOKS, MEMBERS, TOTAL}; + +// version info for migration info +const CONTRACT_NAME: &str = "crates.io:cw4-group"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +// Note, you can use StdResult in some functions where you do not +// make use of the custom errors +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + create(deps, msg.admin, msg.members, env.block.height)?; + Ok(Response::default()) +} + +// create is the instantiation logic with set_contract_version removed so it can more +// easily be imported in other contracts +pub fn create( + mut deps: DepsMut, + admin: Option, + members: Vec, + height: u64, +) -> Result<(), ContractError> { + let admin_addr = admin + .map(|admin| deps.api.addr_validate(&admin)) + .transpose()?; + ADMIN.set(deps.branch(), admin_addr)?; + + let mut total = 0u64; + for member in members.into_iter() { + total += member.weight; + let member_addr = deps.api.addr_validate(&member.addr)?; + MEMBERS.save(deps.storage, &member_addr, &member.weight, height)?; + } + TOTAL.save(deps.storage, &total)?; + + Ok(()) +} + +// And declare a custom Error variant for the ones where you will want to make use of it +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + let api = deps.api; + match msg { + ExecuteMsg::UpdateAdmin { admin } => Ok(ADMIN.execute_update_admin( + deps, + info, + admin.map(|admin| api.addr_validate(&admin)).transpose()?, + )?), + ExecuteMsg::UpdateMembers { add, remove } => { + execute_update_members(deps, env, info, add, remove) + } + ExecuteMsg::AddHook { addr } => { + Ok(HOOKS.execute_add_hook(&ADMIN, deps, info, api.addr_validate(&addr)?)?) + } + ExecuteMsg::RemoveHook { addr } => { + Ok(HOOKS.execute_remove_hook(&ADMIN, deps, info, api.addr_validate(&addr)?)?) + } + } +} + +pub fn execute_update_members( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + add: Vec, + remove: Vec, +) -> Result { + let attributes = vec![ + attr("action", "update_members"), + attr("added", add.len().to_string()), + attr("removed", remove.len().to_string()), + attr("sender", &info.sender), + ]; + + // make the local update + let diff = update_members(deps.branch(), env.block.height, info.sender, add, remove)?; + // call all registered hooks + let messages = HOOKS.prepare_hooks(deps.storage, |h| { + diff.clone().into_cosmos_msg(h).map(SubMsg::new) + })?; + Ok(Response::new() + .add_submessages(messages) + .add_attributes(attributes)) +} + +// the logic from execute_update_members extracted for easier import +pub fn update_members( + deps: DepsMut, + height: u64, + sender: Addr, + to_add: Vec, + to_remove: Vec, +) -> Result { + ADMIN.assert_admin(deps.as_ref(), &sender)?; + + let mut total = TOTAL.load(deps.storage)?; + let mut diffs: Vec = vec![]; + + // add all new members and update total + for add in to_add.into_iter() { + let add_addr = deps.api.addr_validate(&add.addr)?; + MEMBERS.update(deps.storage, &add_addr, height, |old| -> StdResult<_> { + total -= old.unwrap_or_default(); + total += add.weight; + diffs.push(MemberDiff::new(add.addr, old, Some(add.weight))); + Ok(add.weight) + })?; + } + + for remove in to_remove.into_iter() { + let remove_addr = deps.api.addr_validate(&remove)?; + let old = MEMBERS.may_load(deps.storage, &remove_addr)?; + // Only process this if they were actually in the list before + if let Some(weight) = old { + diffs.push(MemberDiff::new(remove, Some(weight), None)); + total -= weight; + MEMBERS.remove(deps.storage, &remove_addr, height)?; + } + } + + TOTAL.save(deps.storage, &total)?; + Ok(MemberChangedHookMsg { diffs }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Member { + addr, + at_height: height, + } => to_binary(&query_member(deps, addr, height)?), + QueryMsg::ListMembers { start_after, limit } => { + to_binary(&list_members(deps, start_after, limit)?) + } + QueryMsg::TotalWeight {} => to_binary(&query_total_weight(deps)?), + QueryMsg::Admin {} => to_binary(&ADMIN.query_admin(deps)?), + QueryMsg::Hooks {} => to_binary(&HOOKS.query_hooks(deps)?), + } +} + +fn query_total_weight(deps: Deps) -> StdResult { + let weight = TOTAL.load(deps.storage)?; + Ok(TotalWeightResponse { weight }) +} + +fn query_member(deps: Deps, addr: String, height: Option) -> StdResult { + let addr = deps.api.addr_validate(&addr)?; + let weight = match height { + Some(h) => MEMBERS.may_load_at_height(deps.storage, &addr, h), + None => MEMBERS.may_load(deps.storage, &addr), + }?; + Ok(MemberResponse { weight }) +} + +// settings for pagination +const MAX_LIMIT: u32 = 30; +const DEFAULT_LIMIT: u32 = 10; + +fn list_members( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let addr = maybe_addr(deps.api, start_after)?; + let start = addr.as_ref().map(Bound::exclusive); + + let members = MEMBERS + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| { + item.map(|(addr, weight)| Member { + addr: addr.into(), + weight, + }) + }) + .collect::>()?; + + Ok(MemberListResponse { members }) +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + use cosmwasm_std::{from_slice, Api, OwnedDeps, Querier, Storage}; + use cw4::{member_key, TOTAL_KEY}; + use cw_controllers::{AdminError, HookError}; + + const INIT_ADMIN: &str = "juan"; + const USER1: &str = "somebody"; + const USER2: &str = "else"; + const USER3: &str = "funny"; + + fn do_instantiate(deps: DepsMut) { + let msg = InstantiateMsg { + admin: Some(INIT_ADMIN.into()), + members: vec![ + Member { + addr: USER1.into(), + weight: 11, + }, + Member { + addr: USER2.into(), + weight: 6, + }, + ], + }; + let info = mock_info("creator", &[]); + instantiate(deps, mock_env(), info, msg).unwrap(); + } + + #[test] + fn proper_instantiation() { + let mut deps = mock_dependencies(); + do_instantiate(deps.as_mut()); + + // it worked, let's query the state + let res = ADMIN.query_admin(deps.as_ref()).unwrap(); + assert_eq!(Some(INIT_ADMIN.into()), res.admin); + + let res = query_total_weight(deps.as_ref()).unwrap(); + assert_eq!(17, res.weight); + } + + #[test] + fn try_member_queries() { + let mut deps = mock_dependencies(); + do_instantiate(deps.as_mut()); + + let member1 = query_member(deps.as_ref(), USER1.into(), None).unwrap(); + assert_eq!(member1.weight, Some(11)); + + let member2 = query_member(deps.as_ref(), USER2.into(), None).unwrap(); + assert_eq!(member2.weight, Some(6)); + + let member3 = query_member(deps.as_ref(), USER3.into(), None).unwrap(); + assert_eq!(member3.weight, None); + + let members = list_members(deps.as_ref(), None, None).unwrap(); + assert_eq!(members.members.len(), 2); + // TODO: assert the set is proper + } + + fn assert_users( + deps: &OwnedDeps, + user1_weight: Option, + user2_weight: Option, + user3_weight: Option, + height: Option, + ) { + let member1 = query_member(deps.as_ref(), USER1.into(), height).unwrap(); + assert_eq!(member1.weight, user1_weight); + + let member2 = query_member(deps.as_ref(), USER2.into(), height).unwrap(); + assert_eq!(member2.weight, user2_weight); + + let member3 = query_member(deps.as_ref(), USER3.into(), height).unwrap(); + assert_eq!(member3.weight, user3_weight); + + // this is only valid if we are not doing a historical query + if height.is_none() { + // compute expected metrics + let weights = vec![user1_weight, user2_weight, user3_weight]; + let sum: u64 = weights.iter().map(|x| x.unwrap_or_default()).sum(); + let count = weights.iter().filter(|x| x.is_some()).count(); + + // TODO: more detailed compare? + let members = list_members(deps.as_ref(), None, None).unwrap(); + assert_eq!(count, members.members.len()); + + let total = query_total_weight(deps.as_ref()).unwrap(); + assert_eq!(sum, total.weight); // 17 - 11 + 15 = 21 + } + } + + #[test] + fn add_new_remove_old_member() { + let mut deps = mock_dependencies(); + do_instantiate(deps.as_mut()); + + // add a new one and remove existing one + let add = vec![Member { + addr: USER3.into(), + weight: 15, + }]; + let remove = vec![USER1.into()]; + + // non-admin cannot update + let height = mock_env().block.height; + let err = update_members( + deps.as_mut(), + height + 5, + Addr::unchecked(USER1), + add.clone(), + remove.clone(), + ) + .unwrap_err(); + assert_eq!(err, AdminError::NotAdmin {}.into()); + + // Test the values from instantiate + assert_users(&deps, Some(11), Some(6), None, None); + // Note all values were set at height, the beginning of that block was all None + assert_users(&deps, None, None, None, Some(height)); + // This will get us the values at the start of the block after instantiate (expected initial values) + assert_users(&deps, Some(11), Some(6), None, Some(height + 1)); + + // admin updates properly + update_members( + deps.as_mut(), + height + 10, + Addr::unchecked(INIT_ADMIN), + add, + remove, + ) + .unwrap(); + + // updated properly + assert_users(&deps, None, Some(6), Some(15), None); + + // snapshot still shows old value + assert_users(&deps, Some(11), Some(6), None, Some(height + 1)); + } + + #[test] + fn add_old_remove_new_member() { + // add will over-write and remove have no effect + let mut deps = mock_dependencies(); + do_instantiate(deps.as_mut()); + + // add a new one and remove existing one + let add = vec![Member { + addr: USER1.into(), + weight: 4, + }]; + let remove = vec![USER3.into()]; + + // admin updates properly + let height = mock_env().block.height; + update_members( + deps.as_mut(), + height, + Addr::unchecked(INIT_ADMIN), + add, + remove, + ) + .unwrap(); + assert_users(&deps, Some(4), Some(6), None, None); + } + + #[test] + fn add_and_remove_same_member() { + // add will over-write and remove have no effect + let mut deps = mock_dependencies(); + do_instantiate(deps.as_mut()); + + // USER1 is updated and remove in the same call, we should remove this an add member3 + let add = vec![ + Member { + addr: USER1.into(), + weight: 20, + }, + Member { + addr: USER3.into(), + weight: 5, + }, + ]; + let remove = vec![USER1.into()]; + + // admin updates properly + let height = mock_env().block.height; + update_members( + deps.as_mut(), + height, + Addr::unchecked(INIT_ADMIN), + add, + remove, + ) + .unwrap(); + assert_users(&deps, None, Some(6), Some(5), None); + } + + #[test] + fn add_remove_hooks() { + // add will over-write and remove have no effect + let mut deps = mock_dependencies(); + do_instantiate(deps.as_mut()); + + let hooks = HOOKS.query_hooks(deps.as_ref()).unwrap(); + assert!(hooks.hooks.is_empty()); + + let contract1 = String::from("hook1"); + let contract2 = String::from("hook2"); + + let add_msg = ExecuteMsg::AddHook { + addr: contract1.clone(), + }; + + // non-admin cannot add hook + let user_info = mock_info(USER1, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + user_info.clone(), + add_msg.clone(), + ) + .unwrap_err(); + assert_eq!(err, HookError::Admin(AdminError::NotAdmin {}).into()); + + // admin can add it, and it appears in the query + let admin_info = mock_info(INIT_ADMIN, &[]); + let _ = execute( + deps.as_mut(), + mock_env(), + admin_info.clone(), + add_msg.clone(), + ) + .unwrap(); + let hooks = HOOKS.query_hooks(deps.as_ref()).unwrap(); + assert_eq!(hooks.hooks, vec![contract1.clone()]); + + // cannot remove a non-registered contract + let remove_msg = ExecuteMsg::RemoveHook { + addr: contract2.clone(), + }; + let err = execute(deps.as_mut(), mock_env(), admin_info.clone(), remove_msg).unwrap_err(); + assert_eq!(err, HookError::HookNotRegistered {}.into()); + + // add second contract + let add_msg2 = ExecuteMsg::AddHook { + addr: contract2.clone(), + }; + let _ = execute(deps.as_mut(), mock_env(), admin_info.clone(), add_msg2).unwrap(); + let hooks = HOOKS.query_hooks(deps.as_ref()).unwrap(); + assert_eq!(hooks.hooks, vec![contract1.clone(), contract2.clone()]); + + // cannot re-add an existing contract + let err = execute(deps.as_mut(), mock_env(), admin_info.clone(), add_msg).unwrap_err(); + assert_eq!(err, HookError::HookAlreadyRegistered {}.into()); + + // non-admin cannot remove + let remove_msg = ExecuteMsg::RemoveHook { addr: contract1 }; + let err = execute(deps.as_mut(), mock_env(), user_info, remove_msg.clone()).unwrap_err(); + assert_eq!(err, HookError::Admin(AdminError::NotAdmin {}).into()); + + // remove the original + let _ = execute(deps.as_mut(), mock_env(), admin_info, remove_msg).unwrap(); + let hooks = HOOKS.query_hooks(deps.as_ref()).unwrap(); + assert_eq!(hooks.hooks, vec![contract2]); + } + + #[test] + fn hooks_fire() { + let mut deps = mock_dependencies(); + do_instantiate(deps.as_mut()); + + let hooks = HOOKS.query_hooks(deps.as_ref()).unwrap(); + assert!(hooks.hooks.is_empty()); + + let contract1 = String::from("hook1"); + let contract2 = String::from("hook2"); + + // register 2 hooks + let admin_info = mock_info(INIT_ADMIN, &[]); + let add_msg = ExecuteMsg::AddHook { + addr: contract1.clone(), + }; + let add_msg2 = ExecuteMsg::AddHook { + addr: contract2.clone(), + }; + for msg in vec![add_msg, add_msg2] { + let _ = execute(deps.as_mut(), mock_env(), admin_info.clone(), msg).unwrap(); + } + + // make some changes - add 3, remove 2, and update 1 + // USER1 is updated and remove in the same call, we should remove this an add member3 + let add = vec![ + Member { + addr: USER1.into(), + weight: 20, + }, + Member { + addr: USER3.into(), + weight: 5, + }, + ]; + let remove = vec![USER2.into()]; + let msg = ExecuteMsg::UpdateMembers { remove, add }; + + // admin updates properly + assert_users(&deps, Some(11), Some(6), None, None); + let res = execute(deps.as_mut(), mock_env(), admin_info, msg).unwrap(); + assert_users(&deps, Some(20), None, Some(5), None); + + // ensure 2 messages for the 2 hooks + assert_eq!(res.messages.len(), 2); + // same order as in the message (adds first, then remove) + let diffs = vec![ + MemberDiff::new(USER1, Some(11), Some(20)), + MemberDiff::new(USER3, None, Some(5)), + MemberDiff::new(USER2, Some(6), None), + ]; + let hook_msg = MemberChangedHookMsg { diffs }; + let msg1 = SubMsg::new(hook_msg.clone().into_cosmos_msg(contract1).unwrap()); + let msg2 = SubMsg::new(hook_msg.into_cosmos_msg(contract2).unwrap()); + assert_eq!(res.messages, vec![msg1, msg2]); + } + + #[test] + fn raw_queries_work() { + // add will over-write and remove have no effect + let mut deps = mock_dependencies(); + do_instantiate(deps.as_mut()); + + // get total from raw key + let total_raw = deps.storage.get(TOTAL_KEY.as_bytes()).unwrap(); + let total: u64 = from_slice(&total_raw).unwrap(); + assert_eq!(17, total); + + // get member votes from raw key + let member2_raw = deps.storage.get(&member_key(USER2)).unwrap(); + let member2: u64 = from_slice(&member2_raw).unwrap(); + assert_eq!(6, member2); + + // and execute misses + let member3_raw = deps.storage.get(&member_key(USER3)); + assert_eq!(None, member3_raw); + } +} diff --git a/contracts/multisig/cw4-group/src/error.rs b/contracts/multisig/cw4-group/src/error.rs new file mode 100644 index 0000000000..82a84fe833 --- /dev/null +++ b/contracts/multisig/cw4-group/src/error.rs @@ -0,0 +1,19 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +use cw_controllers::{AdminError, HookError}; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("{0}")] + Hook(#[from] HookError), + + #[error("{0}")] + Admin(#[from] AdminError), + + #[error("Unauthorized")] + Unauthorized {}, +} diff --git a/contracts/multisig/cw4-group/src/helpers.rs b/contracts/multisig/cw4-group/src/helpers.rs new file mode 100644 index 0000000000..add7451a43 --- /dev/null +++ b/contracts/multisig/cw4-group/src/helpers.rs @@ -0,0 +1,43 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::ops::Deref; + +use cosmwasm_std::{to_binary, Addr, CosmosMsg, StdResult, WasmMsg}; +use cw4::{Cw4Contract, Member}; + +use crate::msg::ExecuteMsg; + +/// Cw4GroupContract is a wrapper around Cw4Contract that provides a lot of helpers +/// for working with cw4-group contracts. +/// +/// It extends Cw4Contract to add the extra calls from cw4-group. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Cw4GroupContract(pub Cw4Contract); + +impl Deref for Cw4GroupContract { + type Target = Cw4Contract; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Cw4GroupContract { + pub fn new(addr: Addr) -> Self { + Cw4GroupContract(Cw4Contract(addr)) + } + + fn encode_msg(&self, msg: ExecuteMsg) -> StdResult { + Ok(WasmMsg::Execute { + contract_addr: self.addr().into(), + msg: to_binary(&msg)?, + funds: vec![], + } + .into()) + } + + pub fn update_members(&self, remove: Vec, add: Vec) -> StdResult { + let msg = ExecuteMsg::UpdateMembers { remove, add }; + self.encode_msg(msg) + } +} diff --git a/contracts/multisig/cw4-group/src/lib.rs b/contracts/multisig/cw4-group/src/lib.rs new file mode 100644 index 0000000000..98208b782f --- /dev/null +++ b/contracts/multisig/cw4-group/src/lib.rs @@ -0,0 +1,7 @@ +pub mod contract; +pub mod error; +pub mod helpers; +pub mod msg; +pub mod state; + +pub use crate::error::ContractError; diff --git a/contracts/multisig/cw4-group/src/msg.rs b/contracts/multisig/cw4-group/src/msg.rs new file mode 100644 index 0000000000..e1759ac549 --- /dev/null +++ b/contracts/multisig/cw4-group/src/msg.rs @@ -0,0 +1,51 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use cw4::Member; + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub struct InstantiateMsg { + /// The admin is the only account that can update the group state. + /// Omit it to make the group immutable. + pub admin: Option, + pub members: Vec, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub enum ExecuteMsg { + /// Change the admin + UpdateAdmin { admin: Option }, + /// apply a diff to the existing members. + /// remove is applied after add, so if an address is in both, it is removed + UpdateMembers { + remove: Vec, + add: Vec, + }, + /// Add a new hook to be informed of all membership changes. Must be called by Admin + AddHook { addr: String }, + /// Remove a hook. Must be called by Admin + RemoveHook { addr: String }, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub enum QueryMsg { + /// Return AdminResponse + Admin {}, + /// Return TotalWeightResponse + TotalWeight {}, + /// Returns MembersListResponse + ListMembers { + start_after: Option, + limit: Option, + }, + /// Returns MemberResponse + Member { + addr: String, + at_height: Option, + }, + /// Shows all registered hooks. Returns HooksResponse. + Hooks {}, +} diff --git a/contracts/multisig/cw4-group/src/state.rs b/contracts/multisig/cw4-group/src/state.rs new file mode 100644 index 0000000000..1b5003c985 --- /dev/null +++ b/contracts/multisig/cw4-group/src/state.rs @@ -0,0 +1,16 @@ +use cosmwasm_std::Addr; +use cw4::TOTAL_KEY; +use cw_controllers::{Admin, Hooks}; +use cw_storage_plus::{Item, SnapshotMap, Strategy}; + +pub const ADMIN: Admin = Admin::new("admin"); +pub const HOOKS: Hooks = Hooks::new("cw4-hooks"); + +pub const TOTAL: Item = Item::new(TOTAL_KEY); + +pub const MEMBERS: SnapshotMap<&Addr, u64> = SnapshotMap::new( + cw4::MEMBERS_KEY, + cw4::MEMBERS_CHECKPOINTS, + cw4::MEMBERS_CHANGELOG, + Strategy::EveryBlock, +); diff --git a/gateway/src/commands/init.rs b/gateway/src/commands/init.rs index c923052b2a..280dba4828 100644 --- a/gateway/src/commands/init.rs +++ b/gateway/src/commands/init.rs @@ -43,6 +43,10 @@ pub struct Init { #[clap(long)] validator_apis: Option, + /// Cosmos wallet mnemonic needed for double spending protection + #[clap(long)] + mnemonic: String, + /// Set this gateway to work in a testnet mode that would allow clients to bypass bandwidth credential requirement #[cfg(all(feature = "eth", not(feature = "coconut")))] #[clap(long)] @@ -57,11 +61,6 @@ pub struct Init { #[cfg(all(feature = "eth", not(feature = "coconut")))] #[clap(long)] validators: Option, - - /// Cosmos wallet mnemonic - #[cfg(all(feature = "eth", not(feature = "coconut")))] - #[clap(long)] - mnemonic: String, } impl From for OverrideConfig { @@ -74,6 +73,7 @@ impl From for OverrideConfig { datastore: init_config.datastore, announce_host: init_config.announce_host, validator_apis: init_config.validator_apis, + mnemonic: Some(init_config.mnemonic), #[cfg(all(feature = "eth", not(feature = "coconut")))] testnet_mode: init_config.testnet_mode, @@ -83,9 +83,6 @@ impl From for OverrideConfig { #[cfg(all(feature = "eth", not(feature = "coconut")))] validators: init_config.validators, - - #[cfg(all(feature = "eth", not(feature = "coconut")))] - mnemonic: Some(init_config.mnemonic), } } } @@ -166,6 +163,7 @@ mod tests { announce_host: Some("foo-announce-host".to_string()), datastore: Some("foo-datastore".to_string()), validator_apis: None, + mnemonic: "a b c".to_string(), }; let config = Config::new(&args.id); diff --git a/gateway/src/commands/mod.rs b/gateway/src/commands/mod.rs index 8ce291f83b..0e998202c0 100644 --- a/gateway/src/commands/mod.rs +++ b/gateway/src/commands/mod.rs @@ -19,9 +19,6 @@ pub(crate) mod upgrade; const DEFAULT_ETH_ENDPOINT: &str = "https://rinkeby.infura.io/v3/00000000000000000000000000000000"; #[cfg(all(not(feature = "eth"), not(feature = "coconut")))] const DEFAULT_VALIDATOR_ENDPOINT: &str = "http://localhost:26657"; -// A dummy mnemonic -#[cfg(all(not(feature = "eth"), not(feature = "coconut")))] -const DEFAULT_MNEMONIC: &str = "typical regret aware used tennis noise resource crisp defy join donate orient army item immense clean emerge globe gift chronic loan flat enter egg"; #[derive(Subcommand)] pub(crate) enum Commands { @@ -50,6 +47,7 @@ pub(crate) struct OverrideConfig { datastore: Option, announce_host: Option, validator_apis: Option, + mnemonic: Option, #[cfg(all(feature = "eth", not(feature = "coconut")))] testnet_mode: bool, @@ -59,9 +57,6 @@ pub(crate) struct OverrideConfig { #[cfg(all(feature = "eth", not(feature = "coconut")))] validators: Option, - - #[cfg(all(feature = "eth", not(feature = "coconut")))] - mnemonic: Option, } pub(crate) async fn execute(args: Cli) { @@ -121,10 +116,13 @@ pub(crate) fn override_config(mut config: Config, args: OverrideConfig) -> Confi config = config.with_custom_persistent_store(datastore_path); } + if let Some(cosmos_mnemonic) = args.mnemonic { + config = config.with_cosmos_mnemonic(cosmos_mnemonic); + } + #[cfg(all(not(feature = "eth"), not(feature = "coconut")))] { config = config.with_custom_validator_nymd(parse_validators(DEFAULT_VALIDATOR_ENDPOINT)); - config = config.with_cosmos_mnemonic(String::from(DEFAULT_MNEMONIC)); config = config.with_eth_endpoint(String::from(DEFAULT_ETH_ENDPOINT)); } @@ -142,10 +140,6 @@ pub(crate) fn override_config(mut config: Config, args: OverrideConfig) -> Confi config = config.with_custom_validator_nymd(parse_validators(&raw_validators)); } - if let Some(cosmos_mnemonic) = args.mnemonic { - config = config.with_cosmos_mnemonic(String::from(cosmos_mnemonic)); - } - if let Some(eth_endpoint) = args.eth_endpoint { config = config.with_eth_endpoint(eth_endpoint); } diff --git a/gateway/src/commands/run.rs b/gateway/src/commands/run.rs index fc7ff4c4e6..e2296ae7a6 100644 --- a/gateway/src/commands/run.rs +++ b/gateway/src/commands/run.rs @@ -43,6 +43,10 @@ pub struct Run { #[clap(long)] validator_apis: Option, + /// Cosmos wallet mnemonic + #[clap(long)] + mnemonic: Option, + /// Set this gateway to work in a testnet mode that would allow clients to bypass bandwidth credential requirement #[cfg(all(feature = "eth", not(feature = "coconut")))] #[clap(long)] @@ -57,11 +61,6 @@ pub struct Run { #[cfg(all(feature = "eth", not(feature = "coconut")))] #[clap(long)] validators: Option, - - /// Cosmos wallet mnemonic - #[cfg(all(feature = "eth", not(feature = "coconut")))] - #[clap(long)] - mnemonic: Option, } impl From for OverrideConfig { @@ -74,6 +73,7 @@ impl From for OverrideConfig { datastore: run_config.datastore, announce_host: run_config.announce_host, validator_apis: run_config.validator_apis, + mnemonic: run_config.mnemonic, #[cfg(all(feature = "eth", not(feature = "coconut")))] testnet_mode: run_config.testnet_mode, @@ -83,9 +83,6 @@ impl From for OverrideConfig { #[cfg(all(feature = "eth", not(feature = "coconut")))] validators: run_config.validators, - - #[cfg(all(feature = "eth", not(feature = "coconut")))] - mnemonic: run_config.mnemonic, } } } diff --git a/gateway/src/config/mod.rs b/gateway/src/config/mod.rs index 2524bfdcf0..b594ade295 100644 --- a/gateway/src/config/mod.rs +++ b/gateway/src/config/mod.rs @@ -138,7 +138,6 @@ impl Config { self } - #[cfg(not(feature = "coconut"))] pub fn with_cosmos_mnemonic(mut self, cosmos_mnemonic: String) -> Self { self.gateway.cosmos_mnemonic = cosmos_mnemonic; self @@ -237,8 +236,7 @@ impl Config { self.gateway.validator_nymd_urls.clone() } - #[cfg(not(feature = "coconut"))] - pub fn get_cosmos_mnemonic(&self) -> String { + pub fn _get_cosmos_mnemonic(&self) -> String { self.gateway.cosmos_mnemonic.clone() } @@ -348,8 +346,7 @@ pub struct Gateway { #[cfg(not(feature = "coconut"))] validator_nymd_urls: Vec, - /// Mnemonic of a cosmos wallet used for checking for double spending. - #[cfg(not(feature = "coconut"))] + /// Mnemonic of a cosmos wallet used in checking for double spending. cosmos_mnemonic: String, /// nym_home_directory specifies absolute path to the home nym gateways directory. @@ -405,7 +402,6 @@ impl Default for Gateway { validator_api_urls: default_api_endpoints(), #[cfg(not(feature = "coconut"))] validator_nymd_urls: default_nymd_endpoints(), - #[cfg(not(feature = "coconut"))] cosmos_mnemonic: "".to_string(), nym_root_directory: Config::default_root_directory(), persistent_storage: Default::default(), diff --git a/gateway/src/node/client_handling/bandwidth.rs b/gateway/src/node/client_handling/bandwidth.rs index d8d1472b0d..28a41568b4 100644 --- a/gateway/src/node/client_handling/bandwidth.rs +++ b/gateway/src/node/client_handling/bandwidth.rs @@ -11,9 +11,6 @@ use credentials::error::Error; #[cfg(not(feature = "coconut"))] use credentials::token::bandwidth::TokenCredential; -#[cfg(feature = "coconut")] -const BANDWIDTH_INDEX: usize = 0; - pub struct Bandwidth { value: u64, } @@ -29,16 +26,8 @@ impl TryFrom for Bandwidth { type Error = Error; fn try_from(credential: Credential) -> Result { - match credential.public_attributes().get(BANDWIDTH_INDEX) { - None => Err(Error::NotEnoughPublicAttributes), - Some(attr) => match <[u8; 8]>::try_from(attr.as_slice()) { - Ok(bandwidth_bytes) => { - let value = u64::from_be_bytes(bandwidth_bytes); - Ok(Self { value }) - } - Err(_) => Err(Error::InvalidBandwidthSize), - }, - } + let value = credential.voucher_value()?; + Ok(Self { value }) } } diff --git a/gateway/src/node/mod.rs b/gateway/src/node/mod.rs index 72c8fbd322..516a104b69 100644 --- a/gateway/src/node/mod.rs +++ b/gateway/src/node/mod.rs @@ -276,7 +276,7 @@ where let erc20_bridge = ERC20Bridge::new( self.config.get_eth_endpoint(), self.config.get_validator_nymd_endpoints(), - self.config.get_cosmos_mnemonic(), + self.config._get_cosmos_mnemonic(), ); let mix_forwarding_channel = self.start_packet_forwarder(); diff --git a/validator-api/Cargo.toml b/validator-api/Cargo.toml index 3e7057d00f..8a29473d50 100644 --- a/validator-api/Cargo.toml +++ b/validator-api/Cargo.toml @@ -57,6 +57,7 @@ validator-client = { path="../common/client-libs/validator-client", features = [ version-checker = { path="../common/version-checker" } coconut-interface = { path = "../common/coconut-interface", optional = true } credentials = { path = "../common/credentials", optional = true } +credential-storage = { path = "../common/credential-storage" } # validator-api needs to be built with RUSTFLAGS="--cfg tokio_unstable" console-subscriber = { version = "0.1.1", optional = true} cfg-if = "1.0" diff --git a/validator-api/src/coconut/client.rs b/validator-api/src/coconut/client.rs index c61a7d474f..34042e9610 100644 --- a/validator-api/src/coconut/client.rs +++ b/validator-api/src/coconut/client.rs @@ -1,35 +1,10 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -use crate::coconut::error::{CoconutError, Result}; -use crate::config::DEFAULT_LOCAL_VALIDATOR; - -use validator_client::nymd::{tx::Hash, NymdClient, QueryNymdClient, TxResponse}; - -use async_trait::async_trait; +use crate::coconut::error::Result; +use validator_client::nymd::TxResponse; #[async_trait] pub trait Client { async fn get_tx(&self, tx_hash: &str) -> Result; } - -pub struct QueryClient { - inner: NymdClient, -} - -impl QueryClient { - pub fn new() -> Result { - let inner = NymdClient::connect(DEFAULT_LOCAL_VALIDATOR, None, None, None)?; - Ok(Self { inner }) - } -} - -#[async_trait] -impl Client for QueryClient { - async fn get_tx(&self, tx_hash: &str) -> Result { - let tx_hash = tx_hash - .parse::() - .map_err(|_| CoconutError::TxHashParseError)?; - Ok(self.inner.get_tx(tx_hash).await?) - } -} diff --git a/validator-api/src/coconut/mod.rs b/validator-api/src/coconut/mod.rs index 0478a19977..ed08605100 100644 --- a/validator-api/src/coconut/mod.rs +++ b/validator-api/src/coconut/mod.rs @@ -3,7 +3,7 @@ pub(crate) mod client; mod deposit; -mod error; +pub(crate) mod error; #[cfg(test)] mod tests; diff --git a/validator-api/src/config/mod.rs b/validator-api/src/config/mod.rs index 98a1846671..c2bbac208c 100644 --- a/validator-api/src/config/mod.rs +++ b/validator-api/src/config/mod.rs @@ -27,6 +27,12 @@ const DEFAULT_GATEWAY_PING_INTERVAL: Duration = Duration::from_secs(60); const DEFAULT_GATEWAY_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5 * 60); const DEFAULT_GATEWAY_CONNECTION_TIMEOUT: Duration = Duration::from_millis(2_500); +#[cfg(not(feature = "coconut"))] +const DEFAULT_ETH_ENDPOINT: &str = "https://rinkeby.infura.io/v3/00000000000000000000000000000000"; +#[cfg(not(feature = "coconut"))] +const DEFAULT_ETH_PRIVATE_KEY: &str = + "0000000000000000000000000000000000000000000000000000000000000001"; + const DEFAULT_TEST_ROUTES: usize = 3; const DEFAULT_MINIMUM_TEST_ROUTES: usize = 1; const DEFAULT_ROUTE_TEST_PACKETS: usize = 1000; @@ -53,6 +59,10 @@ pub struct Config { #[serde(default)] rewarding: Rewarding, + + #[serde(default)] + #[cfg(feature = "coconut")] + coconut_signer: CoconutSigner, } impl NymConfig for Config { @@ -88,9 +98,8 @@ pub struct Base { /// Address of the validator contract managing the network mixnet_contract_address: String, - // Avoid breaking derives for now - #[cfg(feature = "coconut")] - keypair_bs58: String, + /// Mnemonic used for rewarding and/or multisig operations + mnemonic: String, } impl Default for Base { @@ -100,8 +109,7 @@ impl Default for Base { .parse() .expect("default local validator is malformed!"), mixnet_contract_address: DEFAULT_NETWORK.mixnet_contract_address().to_string(), - #[cfg(feature = "coconut")] - keypair_bs58: String::default(), + mnemonic: String::default(), } } } @@ -154,11 +162,8 @@ pub struct NetworkMonitor { #[serde(with = "humantime_serde")] packet_delivery_timeout: Duration, - /// Path to directory containing public/private keys used for bandwidth token purchase. - /// Those are saved in case of emergency, to be able to reclaim bandwidth tokens. - /// The public key is the name of the file, while the private key is the content. - #[cfg(not(feature = "coconut"))] - backup_bandwidth_token_keys_dir: PathBuf, + /// Path to the database containing bandwidth credentials of this client. + credentials_database_path: PathBuf, /// Ethereum private key. #[cfg(not(feature = "coconut"))] @@ -184,9 +189,8 @@ pub struct NetworkMonitor { } impl NetworkMonitor { - #[cfg(not(feature = "coconut"))] - fn default_backup_bandwidth_token_keys_dir() -> PathBuf { - Config::default_data_directory(None).join("backup_bandwidth_token_keys_dir") + fn default_credentials_database_path() -> PathBuf { + Config::default_data_directory(None).join("credentials_database.db") } } @@ -205,12 +209,11 @@ impl Default for NetworkMonitor { gateway_response_timeout: DEFAULT_GATEWAY_RESPONSE_TIMEOUT, gateway_connection_timeout: DEFAULT_GATEWAY_CONNECTION_TIMEOUT, packet_delivery_timeout: DEFAULT_PACKET_DELIVERY_TIMEOUT, + credentials_database_path: Self::default_credentials_database_path(), #[cfg(not(feature = "coconut"))] - backup_bandwidth_token_keys_dir: Self::default_backup_bandwidth_token_keys_dir(), + eth_private_key: DEFAULT_ETH_PRIVATE_KEY.to_string(), #[cfg(not(feature = "coconut"))] - eth_private_key: "".to_string(), - #[cfg(not(feature = "coconut"))] - eth_endpoint: "".to_string(), + eth_endpoint: DEFAULT_ETH_ENDPOINT.to_string(), test_routes: DEFAULT_TEST_ROUTES, minimum_test_routes: DEFAULT_MINIMUM_TEST_ROUTES, route_test_packets: DEFAULT_ROUTE_TEST_PACKETS, @@ -261,9 +264,6 @@ pub struct Rewarding { /// Specifies whether rewarding service is enabled in this process. enabled: bool, - /// Mnemonic (currently of the network monitor) used for rewarding - mnemonic: String, - /// Specifies the minimum percentage of monitor test run data present in order to /// distribute rewards for given interval. /// Note, only values in range 0-100 are valid @@ -274,12 +274,22 @@ impl Default for Rewarding { fn default() -> Self { Rewarding { enabled: false, - mnemonic: String::default(), minimum_interval_monitor_threshold: DEFAULT_MONITOR_THRESHOLD, } } } +#[derive(Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(default)] +#[cfg(feature = "coconut")] +pub struct CoconutSigner { + /// Specifies whether rewarding service is enabled in this process. + enabled: bool, + + /// Base58 encoded signing keypair + keypair_bs58: String, +} + impl Config { pub fn new() -> Self { Config::default() @@ -287,7 +297,7 @@ impl Config { #[cfg(feature = "coconut")] pub fn keypair(&self) -> KeyPair { - KeyPair::try_from_bs58(self.base.keypair_bs58.clone()).unwrap() + KeyPair::try_from_bs58(self.coconut_signer.keypair_bs58.clone()).unwrap() } pub fn with_network_monitor_enabled(mut self, enabled: bool) -> Self { @@ -305,6 +315,12 @@ impl Config { self } + #[cfg(feature = "coconut")] + pub fn with_coconut_signer_enabled(mut self, enabled: bool) -> Self { + self.coconut_signer.enabled = enabled; + self + } + pub fn with_custom_nymd_validator(mut self, validator: Url) -> Self { self.base.local_validator = validator; self @@ -316,13 +332,13 @@ impl Config { } pub fn with_mnemonic>(mut self, mnemonic: S) -> Self { - self.rewarding.mnemonic = mnemonic.into(); + self.base.mnemonic = mnemonic.into(); self } #[cfg(feature = "coconut")] pub fn with_keypair>(mut self, keypair_bs58: S) -> Self { - self.base.keypair_bs58 = keypair_bs58.into(); + self.coconut_signer.keypair_bs58 = keypair_bs58.into(); self } @@ -362,13 +378,17 @@ impl Config { self.network_monitor.enabled } + #[cfg(feature = "coconut")] + pub fn get_coconut_signer_enabled(&self) -> bool { + self.coconut_signer.enabled + } + pub fn get_testnet_mode(&self) -> bool { self.network_monitor.testnet_mode } - #[cfg(not(feature = "coconut"))] - pub fn get_backup_bandwidth_token_keys_dir(&self) -> PathBuf { - self.network_monitor.backup_bandwidth_token_keys_dir.clone() + pub fn get_credentials_database_path(&self) -> PathBuf { + self.network_monitor.credentials_database_path.clone() } #[cfg(not(feature = "coconut"))] @@ -396,7 +416,7 @@ impl Config { } pub fn get_mnemonic(&self) -> String { - self.rewarding.mnemonic.clone() + self.base.mnemonic.clone() } pub fn get_network_monitor_run_interval(&self) -> Duration { diff --git a/validator-api/src/config/template.rs b/validator-api/src/config/template.rs index 1347a63eb2..e3a79ab8f3 100644 --- a/validator-api/src/config/template.rs +++ b/validator-api/src/config/template.rs @@ -58,10 +58,7 @@ gateway_connection_timeout = '{{ network_monitor.gateway_connection_timeout }}' # packets before declaring nodes unreachable. packet_delivery_timeout = '{{ network_monitor.packet_delivery_timeout }}' -# Path to directory containing public/private keys used for bandwidth token purchase. -# Those are saved in case of emergency, to be able to reclaim bandwidth tokens. -# The public key is the name of the file, while the private key is the content. -backup_bandwidth_token_keys_dir = '{{ network_monitor.backup_bandwidth_token_keys_dir }}' +credentials_database_path = '{{ network_monitor.credentials_database_path }}' # Ethereum private key. eth_private_key = '{{ network_monitor.eth_private_key }}' diff --git a/validator-api/src/contract_cache/mod.rs b/validator-api/src/contract_cache/mod.rs index 1830047866..81b01c5c8f 100644 --- a/validator-api/src/contract_cache/mod.rs +++ b/validator-api/src/contract_cache/mod.rs @@ -128,9 +128,13 @@ impl ValidatorCacheRefresher { self.nymd_client.get_gateways(), )?; - let rewarded_set_identities = self.nymd_client.get_rewarded_set_identities().await?; - let (rewarded_set, active_set) = - self.collect_rewarded_and_active_set_details(&mixnodes, rewarded_set_identities); + let (rewarded_set, active_set) = if let Ok(rewarded_set_identities) = + self.nymd_client.get_rewarded_set_identities().await + { + self.collect_rewarded_and_active_set_details(&mixnodes, rewarded_set_identities) + } else { + (Vec::new(), Vec::new()) + }; let epoch_rewarding_params = self.nymd_client.get_current_epoch_reward_params().await?; let current_epoch = self.nymd_client.get_current_epoch().await?; diff --git a/validator-api/src/main.rs b/validator-api/src/main.rs index 04a75b5ebe..0f37b0a150 100644 --- a/validator-api/src/main.rs +++ b/validator-api/src/main.rs @@ -29,7 +29,8 @@ use url::Url; use crate::rewarded_set_updater::RewardedSetUpdater; #[cfg(feature = "coconut")] -use coconut::{client::QueryClient, InternalSignRequest}; +use coconut::InternalSignRequest; +use validator_client::nymd::SigningNymdClient; pub(crate) mod config; pub(crate) mod contract_cache; @@ -55,10 +56,7 @@ const TESTNET_MODE_ARG_NAME: &str = "testnet-mode"; const KEYPAIR_ARG: &str = "keypair"; #[cfg(feature = "coconut")] -const SIGNED_DEPOSITS_ARG: &str = "signed-deposits"; - -#[cfg(feature = "coconut")] -const COCONUT_ONLY_FLAG: &str = "coconut-only"; +const COCONUT_ENABLED: &str = "enable-coconut"; #[cfg(not(feature = "coconut"))] const ETH_ENDPOINT: &str = "eth_endpoint"; @@ -113,10 +111,6 @@ fn long_version() -> String { } fn parse_args<'a>() -> ArgMatches<'a> { - #[cfg(feature = "coconut")] - let monitor_reqs = &[]; - #[cfg(not(feature = "coconut"))] - let monitor_reqs = &[ETH_ENDPOINT, ETH_PRIVATE_KEY]; let build_details = long_version(); let base_app = App::new("Nym Validator API") .version(crate_version!()) @@ -127,14 +121,13 @@ fn parse_args<'a>() -> ArgMatches<'a> { .help("specifies whether a network monitoring is enabled on this API") .long(MONITORING_ENABLED) .short("m") - .requires_all(monitor_reqs) ) .arg( Arg::with_name(REWARDING_ENABLED) .help("specifies whether a network rewarding is enabled on this API") .long(REWARDING_ENABLED) .short("r") - .requires(MONITORING_ENABLED) + .requires_all(&[MONITORING_ENABLED, MNEMONIC_ARG]) ) .arg( Arg::with_name(NYMD_VALIDATOR_ARG) @@ -151,7 +144,6 @@ fn parse_args<'a>() -> ArgMatches<'a> { .long(MNEMONIC_ARG) .help("Mnemonic of the network monitor used for rewarding operators") .takes_value(true) - .requires(REWARDING_ENABLED), ) .arg( Arg::with_name(WRITE_CONFIG_ARG) @@ -170,7 +162,6 @@ fn parse_args<'a>() -> ArgMatches<'a> { .help("Specifies the minimum percentage of monitor test run data present in order to distribute rewards for given interval.") .takes_value(true) .long(REWARDING_MONITOR_THRESHOLD_ARG) - .requires(REWARDING_ENABLED) ) .arg( Arg::with_name(TESTNET_MODE_ARG_NAME) @@ -179,21 +170,19 @@ fn parse_args<'a>() -> ArgMatches<'a> { ); #[cfg(feature = "coconut")] - let base_app = base_app.arg( - Arg::with_name(KEYPAIR_ARG) - .help("Path to the secret key file") - .takes_value(true) - .long(KEYPAIR_ARG), - ).arg( - Arg::with_name(SIGNED_DEPOSITS_ARG) - .help("Path to the directory used to store the already signed deposit transactions. This prevents the validator for double signing for the same deposit") - .takes_value(true) - .long(SIGNED_DEPOSITS_ARG), - ).arg( - Arg::with_name(COCONUT_ONLY_FLAG) - .help("Flag to indicate whether validator api should only be used for credential issuance with no blockchain connection") - .long(COCONUT_ONLY_FLAG), - ); + let base_app = base_app + .arg( + Arg::with_name(KEYPAIR_ARG) + .help("Path to the secret key file") + .takes_value(true) + .long(KEYPAIR_ARG), + ) + .arg( + Arg::with_name(COCONUT_ENABLED) + .help("Flag to indicate whether coconut signer authority is enabled on this API") + .requires_all(&[KEYPAIR_ARG, MNEMONIC_ARG]) + .long(COCONUT_ENABLED), + ); #[cfg(not(feature = "coconut"))] let base_app = base_app.arg( @@ -253,6 +242,11 @@ fn override_config(mut config: Config, matches: &ArgMatches<'_>) -> Config { config = config.with_rewarding_enabled(true) } + #[cfg(feature = "coconut")] + if matches.is_present(COCONUT_ENABLED) { + config = config.with_coconut_signer_enabled(true) + } + if let Some(raw_validators) = matches.value_of(API_VALIDATORS_ARG) { config = config.with_custom_validator_apis(parse_validators(raw_validators)); } @@ -397,7 +391,11 @@ fn expected_monitor_test_runs(config: &Config, interval_length: Duration) -> usi (interval_length.as_secs() / test_delay.as_secs()) as usize } -async fn setup_rocket(config: &Config, liftoff_notify: Arc) -> Result> { +async fn setup_rocket( + config: &Config, + liftoff_notify: Arc, + _nymd_client: Option>, +) -> Result> { // let's build our rocket! let rocket = rocket::build() .attach(setup_cors()?) @@ -413,11 +411,15 @@ async fn setup_rocket(config: &Config, liftoff_notify: Arc) -> Result) -> Result<()> { return Ok(()); } - #[cfg(feature = "coconut")] - if matches.is_present(COCONUT_ONLY_FLAG) { - // this simplifies everything - we just want to run coconut things - return rocket::build() - .attach(setup_cors()?) - .attach(InternalSignRequest::stage( - QueryClient::new()?, - config.keypair(), - ValidatorApiStorage::init(config.get_node_status_api_database_path()).await?, - )) - .launch() - .await - .map_err(|err| err.into()); - } + let signing_nymd_client = if matches.is_present(MNEMONIC_ARG) { + Some(Client::new_signing(&config)) + } else { + None + }; let liftoff_notify = Arc::new(Notify::new()); // let's build our rocket! - let rocket = setup_rocket(&config, Arc::clone(&liftoff_notify)).await?; + let rocket = setup_rocket( + &config, + Arc::clone(&liftoff_notify), + signing_nymd_client.clone(), + ) + .await?; let monitor_builder = setup_network_monitor(&config, system_version, &rocket); let validator_cache = rocket.state::().unwrap().clone(); @@ -485,7 +483,7 @@ async fn run_validator_api(matches: ArgMatches<'static>) -> Result<()> { // if network monitor is disabled, we're not going to be sending any rewarding hence // we're not starting signing client if config.get_network_monitor_enabled() { - let nymd_client = Client::new_signing(&config); + let nymd_client = signing_nymd_client.expect("We should have a signing client here"); let validator_cache_refresher = ValidatorCacheRefresher::new( nymd_client.clone(), config.get_caching_interval(), diff --git a/validator-api/src/network_monitor/mod.rs b/validator-api/src/network_monitor/mod.rs index 855e2a1012..9fea5f28e7 100644 --- a/validator-api/src/network_monitor/mod.rs +++ b/validator-api/src/network_monitor/mod.rs @@ -1,6 +1,7 @@ // Copyright 2021 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use credential_storage::PersistentStorage; use crypto::asymmetric::{encryption, identity}; use futures::channel::mpsc; use gateway_client::bandwidth::BandwidthController; @@ -73,14 +74,16 @@ impl<'a> NetworkMonitorBuilder<'a> { #[cfg(feature = "coconut")] let bandwidth_controller = BandwidthController::new( + credential_storage::initialise_storage(self.config.get_credentials_database_path()) + .await, self.config.get_all_validator_api_endpoints(), - *identity_keypair.public_key(), ); #[cfg(not(feature = "coconut"))] let bandwidth_controller = BandwidthController::new( + credential_storage::initialise_storage(self.config.get_credentials_database_path()) + .await, self.config.get_network_monitor_eth_endpoint(), self.config.get_network_monitor_eth_private_key(), - self.config.get_backup_bandwidth_token_keys_dir(), ) .expect("Could not create bandwidth controller"); @@ -157,7 +160,7 @@ fn new_packet_sender( gateways_status_updater: GatewayClientUpdateSender, local_identity: Arc, max_sending_rate: usize, - bandwidth_controller: BandwidthController, + bandwidth_controller: BandwidthController, testnet_mode: bool, ) -> PacketSender { PacketSender::new( diff --git a/validator-api/src/network_monitor/monitor/sender.rs b/validator-api/src/network_monitor/monitor/sender.rs index 29fd943321..0e590f504b 100644 --- a/validator-api/src/network_monitor/monitor/sender.rs +++ b/validator-api/src/network_monitor/monitor/sender.rs @@ -7,6 +7,7 @@ use crate::network_monitor::monitor::gateway_clients_cache::{ use crate::network_monitor::monitor::gateways_pinger::GatewayPinger; use crate::network_monitor::monitor::receiver::{GatewayClientUpdate, GatewayClientUpdateSender}; use config::defaults::REMAINING_BANDWIDTH_THRESHOLD; +use credential_storage::PersistentStorage; use crypto::asymmetric::identity::{self, PUBLIC_KEY_LENGTH}; use futures::channel::mpsc; use futures::stream::{self, FuturesUnordered, StreamExt}; @@ -97,7 +98,7 @@ struct FreshGatewayClientData { // for coconut bandwidth credentials we currently have no double spending protection, just to // get things running we're re-using the same credential for all gateways all the time. // THIS IS VERY BAD!! - bandwidth_controller: BandwidthController, + bandwidth_controller: BandwidthController, testnet_mode: bool, } @@ -157,7 +158,7 @@ impl PacketSender { gateway_connection_timeout: Duration, max_concurrent_clients: usize, max_sending_rate: usize, - bandwidth_controller: BandwidthController, + bandwidth_controller: BandwidthController, testnet_mode: bool, ) -> Self { PacketSender { diff --git a/validator-api/src/nymd_client.rs b/validator-api/src/nymd_client.rs index 4ede1a1f7c..7aa0560775 100644 --- a/validator-api/src/nymd_client.rs +++ b/validator-api/src/nymd_client.rs @@ -3,6 +3,8 @@ use crate::config::Config; use crate::rewarded_set_updater::error::RewardingError; +#[cfg(feature = "coconut")] +use async_trait::async_trait; use config::defaults::{DEFAULT_NETWORK, DEFAULT_VALIDATOR_API_PORT}; use mixnet_contract_common::Interval; use mixnet_contract_common::{ @@ -21,7 +23,7 @@ use validator_client::nymd::{ }; use validator_client::ValidatorClientError; -pub(crate) struct Client(Arc>>); +pub(crate) struct Client(pub(crate) Arc>>); impl Clone for Client { fn clone(&self) -> Self { @@ -401,3 +403,20 @@ impl Client { } } } + +#[async_trait] +#[cfg(feature = "coconut")] +impl crate::coconut::client::Client for Client +where + C: CosmWasmClient + Sync + Send, +{ + async fn get_tx( + &self, + tx_hash: &str, + ) -> crate::coconut::error::Result { + let tx_hash = tx_hash + .parse::() + .map_err(|_| crate::coconut::error::CoconutError::TxHashParseError)?; + Ok(self.0.read().await.nymd.get_tx(tx_hash).await?) + } +}