diff --git a/Cargo.lock b/Cargo.lock index f26d43d0ab..7d5ac48704 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -909,6 +909,7 @@ dependencies = [ "base64 0.21.0", "cosmwasm-std", "cosmwasm-storage", + "cw-storage-plus", "hex", "rand_chacha 0.3.1", "serde", @@ -2759,6 +2760,7 @@ version = "0.1.0" dependencies = [ "cosmwasm-contract-testing", "cosmwasm-std", + "cw-storage-plus", "mixnet-contract", "mixnet-contract-common", "vesting-contract", diff --git a/common/cosmwasm-smart-contracts/integration-tests/Cargo.toml b/common/cosmwasm-smart-contracts/integration-tests/Cargo.toml index 8208c9d29d..f1a605c5e3 100644 --- a/common/cosmwasm-smart-contracts/integration-tests/Cargo.toml +++ b/common/cosmwasm-smart-contracts/integration-tests/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" [dependencies] cosmwasm-std = "1.0.0" +cw-storage-plus = "0.13.4" cosmwasm-contract-testing = { path = "../testing" } mixnet-contract-common = { path= "../mixnet-contract" } vesting-contract-common = { path= "../vesting-contract" } diff --git a/common/cosmwasm-smart-contracts/integration-tests/tests/integration_test.rs b/common/cosmwasm-smart-contracts/integration-tests/tests/integration_test.rs index 1c8999b531..293f56f761 100644 --- a/common/cosmwasm-smart-contracts/integration-tests/tests/integration_test.rs +++ b/common/cosmwasm-smart-contracts/integration-tests/tests/integration_test.rs @@ -6,8 +6,11 @@ use cosmwasm_contract_testing::{ }; use cosmwasm_std::testing::mock_info; use cosmwasm_std::{ - coin, BlockInfo, Deps, DepsMut, Env, MessageInfo, QueryResponse, Response, Timestamp, + Addr, BankMsg, BlockInfo, Deps, DepsMut, Env, MessageInfo, QueryResponse, Response, Timestamp, }; +use cw_storage_plus::Map; +use mixnet_contract_common::rewarding::PendingRewardResponse; +use vesting_contract::vesting::Account; struct VestingContract; @@ -87,11 +90,15 @@ impl TestableContract for MixnetContract { } } -#[test] -fn multi_mock() { - let mixnet_contract_address = "n14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9sjyvg3g"; - let vesting_contract_address = "n1nc5tatafv6eyq7llkr2gv50ff9e22mnf70qgjlv737ktmt4eswrq73f2nw"; +// this is not directly exported by the vesting contract, but we can easily recreate it +const VESTING_ACCOUNTS: Map<'_, Addr, Account> = Map::new("acc"); +const MIXNET_CONTRACT_ADDRESS: &str = + "n14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9sjyvg3g"; +const VESTING_CONTRACT_ADDRESS: &str = + "n1nc5tatafv6eyq7llkr2gv50ff9e22mnf70qgjlv737ktmt4eswrq73f2nw"; + +fn set_mock() -> MultiContractMock { let current_block = BlockInfo { height: 1928125, time: Timestamp::from_seconds(1676482616), @@ -104,13 +111,13 @@ fn multi_mock() { Some(custom_env.clone()), ) .unwrap() - .with_contract_address(mixnet_contract_address); + .with_contract_address(MIXNET_CONTRACT_ADDRESS); let vesting_mock = ContractState::try_from_state_dump( "contract-states/15.02.23-173000-qwerty-vesting.json", Some(custom_env), ) .unwrap() - .with_contract_address(vesting_contract_address); + .with_contract_address(VESTING_CONTRACT_ADDRESS); let mut multi_mock = MultiContractMock::new(); @@ -118,26 +125,86 @@ fn multi_mock() { multi_mock .add_contract::(vesting_mock) .unwrap(); + multi_mock +} + +#[test] +fn claiming_vesting_delegator_rewards() { + let mut multi_mock = set_mock(); + + let dummy_account = Addr::unchecked("n1ktpuwtweku40uaxcl4uq7mdkkmjeh698g3l3c8"); + + // do some queries to verify state is updated correctly for both contracts + let pending_reward: PendingRewardResponse = multi_mock + .query::( + MIXNET_CONTRACT_ADDRESS, + mixnet_contract_common::QueryMsg::GetPendingDelegatorReward { + address: dummy_account.to_string(), + mix_id: 8, + proxy: Some(VESTING_CONTRACT_ADDRESS.to_string()), + }, + ) + .unwrap(); + let pending_reward_amount = pending_reward.amount_earned.unwrap().amount; + + // we can also get whatever we want directly from storage! + let contract_state = multi_mock.contract_state(VESTING_CONTRACT_ADDRESS).unwrap(); + let vesting_account = contract_state + .load_map_value(&VESTING_ACCOUNTS, dummy_account.clone()) + .unwrap(); + let vesting_balance = vesting_account + .load_balance(contract_state.deps().storage) + .unwrap(); let res = multi_mock.execute_full::( - vesting_contract_address, - mock_info("n1vuz06p7cgagxcaplfezchvpu99u4np7erfxa4c", &[]), - vesting_contract_common::ExecuteMsg::DelegateToMixnode { - mix_id: 7, - amount: coin(1000, "unym"), - on_behalf_of: None, - }, + VESTING_CONTRACT_ADDRESS, + mock_info(dummy_account.as_str(), &[]), + vesting_contract_common::ExecuteMsg::ClaimDelegatorReward { mix_id: 8 }, ); match res { Ok(success) => { - // first we should have emitted a "vesting_delegation" event from the vesting contract - // followed by "v2_pending_delegation" from the mixnet contract - assert_eq!("vesting_delegation", success.steps[0].events[0].ty); - assert_eq!("v2_pending_delegation", success.steps[1].events[0].ty); + println!("{}", success.pretty()); - // println!("{}", success.pretty()) + // check the output + + // unfortunately `ClaimDelegatorReward` doesn't emit any events, but we can see + // it's going to result into a call into the mixnet contract + assert_eq!( + success.steps[0].further_execution[0].contract.as_str(), + MIXNET_CONTRACT_ADDRESS + ); + + // mixnet contract will emit a `v2_withdraw_delegator_reward` event + // and call the vesting contract again + assert_eq!( + "v2_withdraw_delegator_reward", + success.steps[1].events[0].ty + ); + assert_eq!( + success.steps[1].further_execution[0].contract.as_str(), + VESTING_CONTRACT_ADDRESS + ); + // and will move our reward amount into the vesting contract... + assert!(matches!( + &success.steps[1].bank_msgs[0], + BankMsg::Send { to_address, amount } + if to_address == VESTING_CONTRACT_ADDRESS && amount[0].amount == pending_reward_amount + )); + + // and finally the vesting contract will emit the mistyped `track_reaward` event + assert_eq!("track_reaward", success.steps[2].events[0].ty); } Err(err) => panic!("{err}"), } + + // state after execution + let updated_state = multi_mock.contract_state(VESTING_CONTRACT_ADDRESS).unwrap(); + let vesting_account = updated_state + .load_map_value(&VESTING_ACCOUNTS, dummy_account.clone()) + .unwrap(); + let new_vesting_balance = vesting_account + .load_balance(updated_state.deps().storage) + .unwrap(); + assert_eq!(new_vesting_balance, vesting_balance + pending_reward_amount) } diff --git a/common/cosmwasm-smart-contracts/testing/Cargo.toml b/common/cosmwasm-smart-contracts/testing/Cargo.toml index c989f618c0..d9574236f8 100644 --- a/common/cosmwasm-smart-contracts/testing/Cargo.toml +++ b/common/cosmwasm-smart-contracts/testing/Cargo.toml @@ -9,8 +9,12 @@ edition = "2021" base64 = "0.21.0" cosmwasm-std = { version = "1.0.0" } cosmwasm-storage = { version = "1.0.0" } +cw-storage-plus = { version = "0.13.4", optional = true } hex = "0.4.3" rand_chacha = "0.3" serde = { version = "1", features=["derive"] } serde_json = "1" thiserror = "1.0.38" + +[features] +default = ["cw-storage-plus"] \ No newline at end of file diff --git a/common/cosmwasm-smart-contracts/testing/src/contract_mock.rs b/common/cosmwasm-smart-contracts/testing/src/contract_mock.rs index b16342f467..5a8851dc4e 100644 --- a/common/cosmwasm-smart-contracts/testing/src/contract_mock.rs +++ b/common/cosmwasm-smart-contracts/testing/src/contract_mock.rs @@ -1,20 +1,27 @@ // Copyright 2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::mock_api::CW12MockApi; use crate::raw_state::{DecodingError, EncodingError, ImportedContractState, KeyValue}; use crate::AVERAGE_BLOCKTIME_SECS; -use cosmwasm_std::testing::{mock_env, MockApi, MockQuerier, MockStorage}; +use cosmwasm_std::testing::{mock_env, MockQuerier, MockStorage}; use cosmwasm_std::{ Addr, Coin, Deps, DepsMut, Env, Order, QuerierWrapper, StdResult, Storage, Timestamp, TransactionInfo, }; +use serde::de::DeserializeOwned; +use serde::Serialize; use std::collections::HashMap; use std::path::Path; +#[cfg(feature = "cw-storage-plus")] +use cw_storage_plus::{Item, Map, PrimaryKey}; + // extracted into separate struct for easier cloning, access to mock structs, etc. +// we also had to redefine the MockApi struct MockedDependencies { storage: MockStorage, - api: MockApi, + api: CW12MockApi, querier: MockQuerier, // that's a bit annoying. We have to keep track of all balance changes for when we clone the state @@ -115,6 +122,65 @@ impl ContractState { self.deps().querier.query_all_balances(address) } + #[cfg(feature = "cw-storage-plus")] + pub fn save_map_value<'a, K, T>(&mut self, map: &Map<'a, K, T>, k: K, data: &T) -> StdResult<()> + where + T: Serialize + DeserializeOwned, + K: PrimaryKey<'a>, + { + map.save(&mut self.deps.storage, k, data) + } + + #[cfg(feature = "cw-storage-plus")] + pub fn load_map_value<'a, K, T>(&self, map: &Map<'a, K, T>, k: K) -> StdResult + where + T: Serialize + DeserializeOwned, + K: PrimaryKey<'a>, + { + map.load(&self.deps.storage, k) + } + + #[cfg(feature = "cw-storage-plus")] + pub fn may_load_map_value<'a, K, T>(&self, map: &Map<'a, K, T>, k: K) -> StdResult> + where + T: Serialize + DeserializeOwned, + K: PrimaryKey<'a>, + { + map.may_load(&self.deps.storage, k) + } + + #[cfg(feature = "cw-storage-plus")] + pub fn save_item(&mut self, item: &Item, data: &T) -> StdResult<()> + where + T: Serialize + DeserializeOwned, + { + item.save(&mut self.deps.storage, data) + } + + #[cfg(feature = "cw-storage-plus")] + pub fn load_item(&self, item: &Item) -> StdResult + where + T: Serialize + DeserializeOwned, + { + item.load(&self.deps.storage) + } + + #[cfg(feature = "cw-storage-plus")] + pub fn may_load_item(&self, item: &Item) -> StdResult> + where + T: Serialize + DeserializeOwned, + { + item.may_load(&self.deps.storage) + } + + pub fn read_key(&self, key: &[u8]) -> Option> { + self.deps.storage.get(key) + } + + pub fn set_key_value(&mut self, key: &[u8], value: &[u8]) { + self.deps.storage.set(key, value) + } + pub fn deps(&self) -> Deps<'_> { Deps { storage: &self.deps.storage, diff --git a/common/cosmwasm-smart-contracts/testing/src/execution.rs b/common/cosmwasm-smart-contracts/testing/src/execution.rs index e5ad08b84e..3f9637d39e 100644 --- a/common/cosmwasm-smart-contracts/testing/src/execution.rs +++ b/common/cosmwasm-smart-contracts/testing/src/execution.rs @@ -1,14 +1,19 @@ // Copyright 2023 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::raw_msg_to_string; use cosmwasm_std::{Addr, BankMsg, Binary, Coin, Event}; fn format_coins(coins: &[Coin]) -> String { - coins - .iter() - .map(|c| c.to_string()) - .collect::>() - .join(",") + if coins.is_empty() { + "".to_string() + } else { + coins + .iter() + .map(|c| c.to_string()) + .collect::>() + .join(",") + } } // specifically for tokens included in `Execute` that go into the contract @@ -75,12 +80,12 @@ impl FurtherExecution { } pub fn pretty(&self) -> String { - let msg_placeholder = ""; + let msg = raw_msg_to_string(&self.msg); let total_funds = format_coins(&self.funds); format!( - "{} will be called with msg {} and {total_funds} funds", - self.contract, msg_placeholder + "{} will be called with msg {msg} and {total_funds} funds", + self.contract ) } } @@ -101,10 +106,12 @@ impl ExecutionStepResult { let events = format!("EVENTS: {:?}\n", self.events); out.push_str(&events); - if !self.incoming_tokens.is_empty() { + if self.incoming_tokens.iter().any(|c| !c.amount.is_empty()) { out.push_str("MOVED TOKENS (CONTRACTS):\n"); for incoming in &self.incoming_tokens { - out.push_str(&format!("{}\n", incoming.pretty())) + if !incoming.amount.is_empty() { + out.push_str(&format!("{}\n", incoming.pretty())) + } } } diff --git a/common/cosmwasm-smart-contracts/testing/src/lib.rs b/common/cosmwasm-smart-contracts/testing/src/lib.rs index cf05d4bafe..d43acf5f04 100644 --- a/common/cosmwasm-smart-contracts/testing/src/lib.rs +++ b/common/cosmwasm-smart-contracts/testing/src/lib.rs @@ -4,6 +4,7 @@ mod contract_mock; mod error; mod execution; +mod mock_api; mod multi_contract_mock; mod raw_state; mod single_contract_mock; @@ -155,6 +156,14 @@ fn serialize_msg(msg: &M) -> StdResult { cosmwasm_std::to_binary(msg) } +// used only for purposes of providing more informative error messages +fn raw_msg_to_string(raw: &Binary) -> String { + match serde_json::from_slice::(raw.as_slice()) { + Ok(deserialized) => deserialized.to_string(), + Err(_) => "ERR: COULD NOT RECOVER THE ORIGINAL MESSAGE".to_string(), + } +} + pub(crate) mod sealed { use crate::{deserialize_msg, TestableContract}; use cosmwasm_std::{Binary, Deps, DepsMut, Env, MessageInfo, QueryResponse, Response}; diff --git a/common/cosmwasm-smart-contracts/testing/src/mock_api.rs b/common/cosmwasm-smart-contracts/testing/src/mock_api.rs new file mode 100644 index 0000000000..508ad25853 --- /dev/null +++ b/common/cosmwasm-smart-contracts/testing/src/mock_api.rs @@ -0,0 +1,143 @@ +// Copyright 2023 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +// unfortunately we have to redefine cosmwasm' MockApi, +// as in cw1.0 they have set `CANONICAL_LENGTH` to 54 which makes +// `addr_validate` of our existing contracts fail (say of 'n1nc5tatafv6eyq7llkr2gv50ff9e22mnf70qgjlv737ktmt4eswrq73f2nw') +// this has changed in 1.2 but we can't use that version yet... + +use cosmwasm_std::testing::{digit_sum, riffle_shuffle, MockApi}; +use cosmwasm_std::{ + Addr, Api, CanonicalAddr, RecoverPubkeyError, StdError, StdResult, VerificationError, +}; + +const CANONICAL_LENGTH: usize = 90; // n = 45 +const SHUFFLES_ENCODE: usize = 10; +const SHUFFLES_DECODE: usize = 2; + +#[derive(Copy, Clone)] +pub(crate) struct CW12MockApi { + inner: MockApi, + canonical_length: usize, +} + +impl Default for CW12MockApi { + fn default() -> Self { + CW12MockApi { + inner: MockApi::default(), + canonical_length: CANONICAL_LENGTH, + } + } +} + +// whatever we can, hand over to 1.0 MockApi +impl Api for CW12MockApi { + fn addr_validate(&self, input: &str) -> StdResult { + let canonical = self.addr_canonicalize(input)?; + let normalized = self.addr_humanize(&canonical)?; + if input != normalized { + return Err(StdError::generic_err( + "Invalid input: address not normalized", + )); + } + + Ok(Addr::unchecked(input)) + } + + fn addr_canonicalize(&self, input: &str) -> StdResult { + // Dummy input validation. This is more sophisticated for formats like bech32, where format and checksum are validated. + let min_length = 3; + let max_length = self.canonical_length; + if input.len() < min_length { + return Err(StdError::generic_err( + format!("Invalid input: human address too short for this mock implementation (must be >= {min_length})."), + )); + } + if input.len() > max_length { + return Err(StdError::generic_err( + format!("Invalid input: human address too long for this mock implementation (must be <= {max_length})."), + )); + } + + // mimicks formats like hex or bech32 where different casings are valid for one address + let normalized = input.to_lowercase(); + + let mut out = Vec::from(normalized); + + // pad to canonical length with NULL bytes + out.resize(self.canonical_length, 0x00); + // content-dependent rotate followed by shuffle to destroy + // the most obvious structure (https://github.com/CosmWasm/cosmwasm/issues/552) + let rotate_by = digit_sum(&out) % self.canonical_length; + out.rotate_left(rotate_by); + for _ in 0..SHUFFLES_ENCODE { + out = riffle_shuffle(&out); + } + Ok(out.into()) + } + + fn addr_humanize(&self, canonical: &CanonicalAddr) -> StdResult { + if canonical.len() != self.canonical_length { + return Err(StdError::generic_err( + "Invalid input: canonical address length not correct", + )); + } + + let mut tmp: Vec = canonical.clone().into(); + // Shuffle two more times which restored the original value (24 elements are back to original after 20 rounds) + for _ in 0..SHUFFLES_DECODE { + tmp = riffle_shuffle(&tmp); + } + // Rotate back + let rotate_by = digit_sum(&tmp) % self.canonical_length; + tmp.rotate_right(rotate_by); + // Remove NULL bytes (i.e. the padding) + let trimmed = tmp.into_iter().filter(|&x| x != 0x00).collect(); + // decode UTF-8 bytes into string + let human = String::from_utf8(trimmed)?; + Ok(Addr::unchecked(human)) + } + + fn secp256k1_verify( + &self, + message_hash: &[u8], + signature: &[u8], + public_key: &[u8], + ) -> Result { + self.inner + .secp256k1_verify(message_hash, signature, public_key) + } + + fn secp256k1_recover_pubkey( + &self, + message_hash: &[u8], + signature: &[u8], + recovery_param: u8, + ) -> Result, RecoverPubkeyError> { + self.inner + .secp256k1_recover_pubkey(message_hash, signature, recovery_param) + } + + fn ed25519_verify( + &self, + message: &[u8], + signature: &[u8], + public_key: &[u8], + ) -> Result { + self.inner.ed25519_verify(message, signature, public_key) + } + + fn ed25519_batch_verify( + &self, + messages: &[&[u8]], + signatures: &[&[u8]], + public_keys: &[&[u8]], + ) -> Result { + self.inner + .ed25519_batch_verify(messages, signatures, public_keys) + } + + fn debug(&self, message: &str) { + self.inner.debug(message) + } +} diff --git a/common/cosmwasm-smart-contracts/testing/src/multi_contract_mock.rs b/common/cosmwasm-smart-contracts/testing/src/multi_contract_mock.rs index bf237b7b58..b89384be93 100644 --- a/common/cosmwasm-smart-contracts/testing/src/multi_contract_mock.rs +++ b/common/cosmwasm-smart-contracts/testing/src/multi_contract_mock.rs @@ -5,7 +5,7 @@ use crate::contract_mock::ContractState; use crate::execution::{ CrossContractTokenMove, ExecutionResult, ExecutionStepResult, FurtherExecution, }; -use crate::{sealed, serialize_msg, test_rng, MockingError, TestableContract}; +use crate::{raw_msg_to_string, sealed, serialize_msg, test_rng, MockingError, TestableContract}; use cosmwasm_std::testing::{mock_env, mock_info}; use cosmwasm_std::{Addr, Binary, CosmosMsg, Env, MessageInfo, ReplyOn, Response, WasmMsg}; use rand_chacha::rand_core::RngCore; @@ -183,6 +183,34 @@ impl MultiContractMock { Ok(()) } + pub fn contract_state( + &self, + contract_address: impl Into, + ) -> Result<&ContractState, MockingError> { + let addr = Addr::unchecked(contract_address.into()); + let contract = + self.contracts + .get(&addr) + .ok_or_else(|| MockingError::NonExistentContract { + address: addr.clone(), + })?; + Ok(&contract.state) + } + + pub fn contract_state_mut( + &mut self, + contract_address: impl Into, + ) -> Result<&mut ContractState, MockingError> { + let addr = Addr::unchecked(contract_address.into()); + let contract = + self.contracts + .get_mut(&addr) + .ok_or_else(|| MockingError::NonExistentContract { + address: addr.clone(), + })?; + Ok(&mut contract.state) + } + // TODO: add support for sub msgs in instantiate response pub fn instantiate( &mut self, @@ -332,14 +360,6 @@ impl MultiContractMock { } } -// used only for purposes of providing more informative error messages -fn raw_msg_to_string(raw: &Binary) -> String { - match serde_json::from_slice::(raw.as_slice()) { - Ok(deserialized) => deserialized.to_string(), - Err(_) => "ERR: COULD NOT RECOVER THE ORIGINAL MESSAGE".to_string(), - } -} - #[cfg(test)] mod tests { use super::*;