diff --git a/Cargo.lock b/Cargo.lock index 6742e170e..77b3e8354 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -811,10 +811,11 @@ dependencies = [ "cw-utils 1.0.3", "cw2 1.1.2", "cw20 1.1.2", - "cw20-base 1.1.2", + "cw20-hooks", "cw20-stake 2.4.2", "dao-dao-core", "dao-interface", + "dao-testing", "dao-voting-cw20-staked", "thiserror", ] @@ -1270,6 +1271,24 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cw20-hooks" +version = "2.4.2" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.2", + "cw-multi-test", + "cw-ownable", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", + "cw20 1.1.2", + "cw20-base 1.1.2", + "semver", + "thiserror", +] + [[package]] name = "cw20-stake" version = "0.2.6" @@ -1572,6 +1591,31 @@ dependencies = [ "thiserror", ] +[[package]] +name = "dao-cw20-transfer-rules" +version = "2.4.2" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "cw-multi-test", + "cw-ownable", + "cw-paginate-storage 2.4.2", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", + "cw20 1.1.2", + "cw20-hooks", + "cw20-stake 2.4.2", + "dao-dao-core", + "dao-interface", + "dao-pre-propose-single", + "dao-proposal-single", + "dao-testing", + "dao-voting 2.4.2", + "dao-voting-cw20-staked", + "thiserror", +] + [[package]] name = "dao-cw721-extensions" version = "2.4.2" @@ -1595,12 +1639,13 @@ dependencies = [ "cw-utils 1.0.3", "cw2 1.1.2", "cw20 1.1.2", - "cw20-base 1.1.2", + "cw20-hooks", "cw721 0.18.0", "cw721-base 0.18.0", "dao-dao-macros", "dao-interface", "dao-proposal-sudo", + "dao-testing", "dao-voting-cw20-balance", "thiserror", ] @@ -1661,7 +1706,7 @@ dependencies = [ "cw2 1.1.2", "cw20 0.13.4", "cw20 1.1.2", - "cw20-base 1.1.2", + "cw20-hooks", "cw20-stake 0.2.6", "cw20-stake 2.4.2", "cw20-staked-balance-voting", @@ -1837,6 +1882,7 @@ dependencies = [ "dao-hooks", "dao-interface", "dao-proposal-single", + "dao-testing", "dao-voting 2.4.2", "dao-voting-cw20-balance", "thiserror", @@ -1856,7 +1902,7 @@ dependencies = [ "cw-utils 1.0.3", "cw2 1.1.2", "cw20 1.1.2", - "cw20-base 1.1.2", + "cw20-hooks", "cw20-stake 2.4.2", "cw4 1.1.2", "cw4-group 1.1.2", @@ -1967,6 +2013,7 @@ dependencies = [ "cw2 1.1.2", "cw20 1.1.2", "cw20-base 1.1.2", + "cw20-hooks", "cw20-stake 2.4.2", "cw4 1.1.2", "cw4-group 1.1.2", @@ -2033,7 +2080,7 @@ dependencies = [ "cw-utils 1.0.3", "cw2 1.1.2", "cw20 1.1.2", - "cw20-base 1.1.2", + "cw20-hooks", "dao-dao-macros", "dao-interface", "thiserror", @@ -2050,7 +2097,7 @@ dependencies = [ "cw-utils 1.0.3", "cw2 1.1.2", "cw20 1.1.2", - "cw20-base 1.1.2", + "cw20-hooks", "cw20-stake 2.4.2", "dao-dao-macros", "dao-interface", @@ -2838,7 +2885,7 @@ dependencies = [ "cw-utils 1.0.3", "cw-vesting", "cw20 1.1.2", - "cw20-base 1.1.2", + "cw20-hooks", "cw20-stake 2.4.2", "cw721 0.18.0", "cw721-base 0.18.0", @@ -3773,9 +3820,9 @@ dependencies = [ [[package]] name = "serde-json-wasm" -version = "0.5.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e9213a07d53faa0b8dd81e767a54a8188a242fdb9be99ab75ec576a774bfdd7" +checksum = "16a62a1fad1e1828b24acac8f2b468971dade7b8c3c2e672bcadefefb1f8c137" dependencies = [ "serde", ] diff --git a/Cargo.toml b/Cargo.toml index cf72e327a..cd47952a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ prost-types = { version = "0.12.3", default-features = false } quote = "1.0" rand = "0.8" schemars = "0.8" +semver = "1.0" serde = { version = "1.0", default-features = false, features = ["derive"] } serde-cw-value = "0.7" serde_json = "1.0" @@ -90,6 +91,7 @@ cw-tokenfactory-issuer = { path = "./contracts/external/cw-tokenfactory-issuer", cw-tokenfactory-types = { path = "./packages/cw-tokenfactory-types", version = "2.4.2", default-features = false } cw-vesting = { path = "./contracts/external/cw-vesting", version = "2.4.2" } cw-wormhole = { path = "./packages/cw-wormhole", version = "2.4.2" } +cw20-hooks = { path = "./contracts/external/cw20-hooks", version = "2.4.2" } cw20-stake = { path = "./contracts/staking/cw20-stake", version = "2.4.2" } cw721-controllers = { path = "./packages/cw721-controllers", version = "2.4.2" } cw721-roles = { path = "./contracts/external/cw721-roles", version = "2.4.2" } diff --git a/ci/bootstrap-env/src/main.rs b/ci/bootstrap-env/src/main.rs index c1c321c91..d89c18e26 100644 --- a/ci/bootstrap-env/src/main.rs +++ b/ci/bootstrap-env/src/main.rs @@ -57,7 +57,7 @@ fn main() -> Result<()> { code_id: orc.contract_map.code_id("dao_voting_cw20_staked")?, msg: to_json_binary(&dao_voting_cw20_staked::msg::InstantiateMsg { token_info: dao_voting_cw20_staked::msg::TokenInfo::New { - code_id: orc.contract_map.code_id("cw20_base")?, + code_id: orc.contract_map.code_id("cw20_hooks")?, label: "DAO DAO Gov token".to_string(), name: "DAO".to_string(), symbol: "DAO".to_string(), @@ -147,7 +147,7 @@ fn main() -> Result<()> { println!( "NEXT_PUBLIC_CW20_CODE_ID={}", - orc.contract_map.code_id("cw20_base")? + orc.contract_map.code_id("cw20_hooks")? ); println!( "NEXT_PUBLIC_CW4GROUP_CODE_ID={}", diff --git a/ci/integration-tests/Cargo.toml b/ci/integration-tests/Cargo.toml index 6291ec502..822a78c5b 100644 --- a/ci/integration-tests/Cargo.toml +++ b/ci/integration-tests/Cargo.toml @@ -12,7 +12,7 @@ edition = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] cosm-orc = { workspace = true } cw20 = { workspace = true } -cw20-base = { workspace = true } +cw20-hooks = { workspace = true } cw721-base = { workspace = true } cw721-roles = { workspace = true } cw721 = { workspace = true } diff --git a/ci/integration-tests/src/helpers/helper.rs b/ci/integration-tests/src/helpers/helper.rs index e3bb76558..9845363ff 100644 --- a/ci/integration-tests/src/helpers/helper.rs +++ b/ci/integration-tests/src/helpers/helper.rs @@ -41,7 +41,7 @@ pub fn create_dao( code_id: chain.orc.contract_map.code_id("dao_voting_cw20_staked")?, msg: to_json_binary(&dao_voting_cw20_staked::msg::InstantiateMsg { token_info: dao_voting_cw20_staked::msg::TokenInfo::New { - code_id: chain.orc.contract_map.code_id("cw20_base")?, + code_id: chain.orc.contract_map.code_id("cw20_hooks")?, label: "DAO DAO Gov token".to_string(), name: "DAO".to_string(), symbol: "DAO".to_string(), @@ -106,7 +106,7 @@ pub fn create_dao( .orc .instantiate("dao_dao_core", op_name, &msg, key, None, vec![])?; - // add proposal, pre-propose, voting, cw20_stake, and cw20_base + // add proposal, pre-propose, voting, cw20_stake, and cw20_hooks // contracts to the orc contract map. let state: DumpStateResponse = chain @@ -160,7 +160,7 @@ pub fn create_dao( .contract_map .add_address("cw20_stake", cw20_stake) .unwrap(); - let cw20_base: String = chain + let cw20_hooks: String = chain .orc .query( "dao_voting_cw20_staked", @@ -172,7 +172,7 @@ pub fn create_dao( chain .orc .contract_map - .add_address("cw20_base", cw20_base) + .add_address("cw20_hooks", cw20_hooks) .unwrap(); Ok(DaoState { @@ -185,7 +185,7 @@ pub fn stake_tokens(chain: &mut Chain, how_many: u128, key: &SigningKey) { chain .orc .execute( - "cw20_base", + "cw20_hooks", "send_and_stake_cw20", &cw20::Cw20ExecuteMsg::Send { contract: chain.orc.contract_map.address("cw20_stake").unwrap(), @@ -221,8 +221,8 @@ pub fn create_proposal( chain .orc .execute( - "cw20_base", - "cw20_base_increase_allowance", + "cw20_hooks", + "cw20_hooks_increase_allowance", &cw20::Cw20ExecuteMsg::IncreaseAllowance { spender: chain .orc diff --git a/ci/integration-tests/src/tests/cw20_stake_test.rs b/ci/integration-tests/src/tests/cw20_stake_test.rs index 4250441d5..94f8d5331 100644 --- a/ci/integration-tests/src/tests/cw20_stake_test.rs +++ b/ci/integration-tests/src/tests/cw20_stake_test.rs @@ -69,14 +69,14 @@ fn execute_stake_tokens(chain: &mut Chain) { chain .orc .contract_map - .add_address("cw20_base", config.token_address.as_str()) + .add_address("cw20_hooks", config.token_address.as_str()) .unwrap(); chain .orc .execute( - "cw20_base", + "cw20_hooks", "exc_stake_stake_tokens", - &cw20_base::msg::ExecuteMsg::Send { + &cw20_hooks::msg::ExecuteMsg::Send { contract: staking_addr, amount: Uint128::new(100), msg: to_json_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), diff --git a/contracts/dao-dao-core/Cargo.toml b/contracts/dao-dao-core/Cargo.toml index ac8e2060d..e2e3fe263 100644 --- a/contracts/dao-dao-core/Cargo.toml +++ b/contracts/dao-dao-core/Cargo.toml @@ -32,7 +32,8 @@ cw-core-v1 = { workspace = true, features = ["library"] } [dev-dependencies] cw-multi-test = { workspace = true, features = ["stargate"] } -cw20-base = { workspace = true } +cw20-hooks = { workspace = true } cw721-base = { workspace = true } dao-proposal-sudo = { workspace = true } dao-voting-cw20-balance = { workspace = true } +dao-testing = { workspace = true } diff --git a/contracts/dao-dao-core/src/tests.rs b/contracts/dao-dao-core/src/tests.rs index 81dd040cb..328501ca3 100644 --- a/contracts/dao-dao-core/src/tests.rs +++ b/contracts/dao-dao-core/src/tests.rs @@ -17,6 +17,7 @@ use dao_interface::{ state::{Admin, Config, ModuleInstantiateInfo, ProposalModule, ProposalModuleStatus}, voting::{InfoResponse, VotingPowerAtHeightResponse}, }; +use dao_testing::contracts::cw20_hooks_contract; use crate::{ contract::{derive_proposal_module_prefix, migrate, CONTRACT_NAME, CONTRACT_VERSION}, @@ -26,15 +27,6 @@ use crate::{ const CREATOR_ADDR: &str = "creator"; -fn cw20_contract() -> Box> { - let contract = ContractWrapper::new( - cw20_base::contract::execute, - cw20_base::contract::instantiate, - cw20_base::contract::query, - ); - Box::new(contract) -} - fn cw721_contract() -> Box> { let contract = ContractWrapper::new( cw721_base::entry::execute, @@ -96,16 +88,17 @@ fn instantiate_gov(app: &mut App, code_id: u64, msg: InstantiateMsg) -> Addr { fn test_instantiate_with_n_gov_modules(n: usize) { let mut app = App::default(); - let cw20_id = app.store_code(cw20_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let gov_id = app.store_code(cw_core_contract()); - let cw20_instantiate = cw20_base::msg::InstantiateMsg { + let cw20_instantiate = cw20_hooks::msg::InstantiateMsg { name: "DAO".to_string(), symbol: "DAO".to_string(), decimals: 6, initial_balances: vec![], mint: None, marketing: None, + owner: None, }; let instantiate = InstantiateMsg { dao_uri: None, @@ -173,19 +166,20 @@ fn test_valid_instantiate() { } #[test] -#[should_panic(expected = "Error parsing into type cw20_base::msg::InstantiateMsg: Invalid type")] +#[should_panic(expected = "Error parsing into type cw20_hooks::msg::InstantiateMsg: Invalid type")] fn test_instantiate_with_submessage_failure() { let mut app = App::default(); - let cw20_id = app.store_code(cw20_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let gov_id = app.store_code(cw_core_contract()); - let cw20_instantiate = cw20_base::msg::InstantiateMsg { + let cw20_instantiate = cw20_hooks::msg::InstantiateMsg { name: "DAO".to_string(), symbol: "DAO".to_string(), decimals: 6, initial_balances: vec![], mint: None, marketing: None, + owner: None, }; let mut governance_modules = (0..3) @@ -975,7 +969,7 @@ fn do_standard_instantiate(auto_add: bool, admin: Option) -> (Addr, App) let govmod_id = app.store_code(sudo_proposal_contract()); let voting_id = app.store_code(cw20_balances_voting()); let gov_id = app.store_code(cw_core_contract()); - let cw20_id = app.store_code(cw20_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let govmod_instantiate = dao_proposal_sudo::msg::InstantiateMsg { root: CREATOR_ADDR.to_string(), @@ -1692,7 +1686,7 @@ fn test_list_items() { let govmod_id = app.store_code(sudo_proposal_contract()); let voting_id = app.store_code(cw20_balances_voting()); let gov_id = app.store_code(cw_core_contract()); - let cw20_id = app.store_code(cw20_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let govmod_instantiate = dao_proposal_sudo::msg::InstantiateMsg { root: CREATOR_ADDR.to_string(), @@ -1811,7 +1805,7 @@ fn test_instantiate_with_items() { let govmod_id = app.store_code(sudo_proposal_contract()); let voting_id = app.store_code(cw20_balances_voting()); let gov_id = app.store_code(cw_core_contract()); - let cw20_id = app.store_code(cw20_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let govmod_instantiate = dao_proposal_sudo::msg::InstantiateMsg { root: CREATOR_ADDR.to_string(), @@ -1927,18 +1921,19 @@ fn test_instantiate_with_items() { fn test_cw20_receive_auto_add() { let (gov_addr, mut app) = do_standard_instantiate(true, None); - let cw20_id = app.store_code(cw20_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let another_cw20 = app .instantiate_contract( cw20_id, Addr::unchecked(CREATOR_ADDR), - &cw20_base::msg::InstantiateMsg { + &cw20_hooks::msg::InstantiateMsg { name: "DAO".to_string(), symbol: "DAO".to_string(), decimals: 6, initial_balances: vec![], mint: None, marketing: None, + owner: None, }, &[], "another-token", @@ -2074,18 +2069,19 @@ fn test_cw20_receive_auto_add() { fn test_cw20_receive_no_auto_add() { let (gov_addr, mut app) = do_standard_instantiate(false, None); - let cw20_id = app.store_code(cw20_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let another_cw20 = app .instantiate_contract( cw20_id, Addr::unchecked(CREATOR_ADDR), - &cw20_base::msg::InstantiateMsg { + &cw20_hooks::msg::InstantiateMsg { name: "DAO".to_string(), symbol: "DAO".to_string(), decimals: 6, initial_balances: vec![], mint: None, marketing: None, + owner: None, }, &[], "another-token", @@ -2654,7 +2650,7 @@ fn test_migrate_from_compatible() { let govmod_id = app.store_code(sudo_proposal_contract()); let voting_id = app.store_code(cw20_balances_voting()); let gov_id = app.store_code(cw_core_contract()); - let cw20_id = app.store_code(cw20_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let govmod_instantiate = dao_proposal_sudo::msg::InstantiateMsg { root: CREATOR_ADDR.to_string(), @@ -2743,7 +2739,7 @@ fn test_migrate_from_beta() { let voting_id = app.store_code(cw20_balances_voting()); let core_id = app.store_code(cw_core_contract()); let v1_core_id = app.store_code(v1_cw_core_contract()); - let cw20_id = app.store_code(cw20_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let proposal_instantiate = dao_proposal_sudo::msg::InstantiateMsg { root: CREATOR_ADDR.to_string(), diff --git a/contracts/external/cw-fund-distributor/Cargo.toml b/contracts/external/cw-fund-distributor/Cargo.toml index 558478ae3..c942ba25a 100644 --- a/contracts/external/cw-fund-distributor/Cargo.toml +++ b/contracts/external/cw-fund-distributor/Cargo.toml @@ -32,4 +32,5 @@ cw-paginate-storage = { workspace = true } [dev-dependencies] dao-dao-core = { workspace = true, features = ["library"] } cw-multi-test = { workspace = true } -cw20-base = { workspace = true, features = ["library"] } +cw20-hooks = { workspace = true, features = ["library"] } +dao-testing = { workspace = true } diff --git a/contracts/external/cw-fund-distributor/schema/cw-fund-distributor.json b/contracts/external/cw-fund-distributor/schema/cw-fund-distributor.json index 19a0541ca..274034b64 100644 --- a/contracts/external/cw-fund-distributor/schema/cw-fund-distributor.json +++ b/contracts/external/cw-fund-distributor/schema/cw-fund-distributor.json @@ -344,7 +344,7 @@ ], "properties": { "code_id": { - "description": "Code ID for cw20 token contract.", + "description": "Code ID for cw20-hooks token contract.", "type": "integer", "format": "uint64", "minimum": 0.0 diff --git a/contracts/external/cw-fund-distributor/src/testing/adversarial_tests.rs b/contracts/external/cw-fund-distributor/src/testing/adversarial_tests.rs index 8df033529..ba2678944 100644 --- a/contracts/external/cw-fund-distributor/src/testing/adversarial_tests.rs +++ b/contracts/external/cw-fund-distributor/src/testing/adversarial_tests.rs @@ -4,6 +4,7 @@ use cosmwasm_std::{to_json_binary, Addr, Binary, Coin, Empty, Uint128}; use cw20::{BalanceResponse, Cw20Coin}; use cw_multi_test::{next_block, App, BankSudo, Contract, ContractWrapper, Executor, SudoMsg}; use cw_utils::Duration; +use dao_testing::contracts::cw20_hooks_contract; const CREATOR_ADDR: &str = "creator"; const FEE_DENOM: &str = "ujuno"; @@ -23,15 +24,6 @@ fn distributor_contract() -> Box> { Box::new(contract) } -fn cw20_contract() -> Box> { - let contract = ContractWrapper::new( - cw20_base::contract::execute, - cw20_base::contract::instantiate, - cw20_base::contract::query, - ); - Box::new(contract) -} - fn staked_balances_voting_contract() -> Box> { let contract = ContractWrapper::new( dao_voting_cw20_staked::contract::execute, @@ -58,14 +50,15 @@ fn instantiate_cw20( name: String, symbol: String, ) -> Addr { - let cw20_id = app.store_code(cw20_contract()); - let msg = cw20_base::msg::InstantiateMsg { + let cw20_id = app.store_code(cw20_hooks_contract()); + let msg = cw20_hooks::msg::InstantiateMsg { name, symbol, decimals: 6, initial_balances, mint: None, marketing: None, + owner: None, }; app.instantiate_contract(cw20_id, sender, &msg, &[], "cw20", None) @@ -75,7 +68,7 @@ fn instantiate_cw20( fn setup_test(initial_balances: Vec) -> BaseTest { let mut app = App::default(); let distributor_id = app.store_code(distributor_contract()); - let cw20_id = app.store_code(cw20_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let voting_id = app.store_code(staked_balances_voting_contract()); let stake_cw20_id = app.store_code(cw20_staking_contract()); @@ -124,7 +117,7 @@ fn setup_test(initial_balances: Vec) -> BaseTest { app.execute_contract( Addr::unchecked(address), token_contract.clone(), - &cw20_base::msg::ExecuteMsg::Send { + &cw20_hooks::msg::ExecuteMsg::Send { contract: staking_contract.to_string(), amount, msg: to_json_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), diff --git a/contracts/external/cw-fund-distributor/src/testing/tests.rs b/contracts/external/cw-fund-distributor/src/testing/tests.rs index f3269dd3f..87110fb07 100644 --- a/contracts/external/cw-fund-distributor/src/testing/tests.rs +++ b/contracts/external/cw-fund-distributor/src/testing/tests.rs @@ -6,6 +6,7 @@ use crate::ContractError; use cosmwasm_std::{to_json_binary, Addr, Binary, Coin, Empty, Uint128, WasmMsg}; use cw20::Cw20Coin; use cw_multi_test::{next_block, App, BankSudo, Contract, ContractWrapper, Executor, SudoMsg}; +use dao_testing::contracts::cw20_hooks_contract; use crate::msg::ExecuteMsg::{ClaimAll, ClaimCW20, ClaimNatives}; use crate::msg::QueryMsg::TotalPower; @@ -25,15 +26,6 @@ fn distributor_contract() -> Box> { Box::new(contract) } -fn cw20_contract() -> Box> { - let contract = ContractWrapper::new( - cw20_base::contract::execute, - cw20_base::contract::instantiate, - cw20_base::contract::query, - ); - Box::new(contract) -} - fn staked_balances_voting_contract() -> Box> { let contract = ContractWrapper::new( dao_voting_cw20_staked::contract::execute, @@ -62,7 +54,7 @@ struct BaseTest { fn setup_test(initial_balances: Vec) -> BaseTest { let mut app = App::default(); let distributor_id = app.store_code(distributor_contract()); - let cw20_id = app.store_code(cw20_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let voting_id = app.store_code(staked_balances_voting_contract()); let stake_cw20_id = app.store_code(cw20_staking_contract()); @@ -111,7 +103,7 @@ fn setup_test(initial_balances: Vec) -> BaseTest { app.execute_contract( Addr::unchecked(address), token_contract.clone(), - &cw20_base::msg::ExecuteMsg::Send { + &cw20_hooks::msg::ExecuteMsg::Send { contract: staking_contract.to_string(), amount, msg: to_json_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), @@ -266,7 +258,7 @@ fn test_instantiate_fails_given_invalid_voting_contract_address() { fn test_instantiate_fails_zero_voting_power() { let mut app = App::default(); let distributor_id = app.store_code(distributor_contract()); - let cw20_id = app.store_code(cw20_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let voting_id = app.store_code(staked_balances_voting_contract()); let stake_cw20_id = app.store_code(cw20_staking_contract()); diff --git a/contracts/external/cw20-hooks/.cargo/config b/contracts/external/cw20-hooks/.cargo/config new file mode 100644 index 000000000..8d4bc738b --- /dev/null +++ b/contracts/external/cw20-hooks/.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/external/cw20-hooks/Cargo.toml b/contracts/external/cw20-hooks/Cargo.toml new file mode 100644 index 000000000..7c09f938d --- /dev/null +++ b/contracts/external/cw20-hooks/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "cw20-hooks" +authors = ["Jake Hartnell", "Noah Saso"] +description = "A CW20 contract that supports transfer hooks." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-controllers = { workspace = true } +cw-ownable = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +cw20-base = { workspace = true } +semver = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } diff --git a/contracts/external/cw20-hooks/README.md b/contracts/external/cw20-hooks/README.md new file mode 100644 index 000000000..6d55c5d9d --- /dev/null +++ b/contracts/external/cw20-hooks/README.md @@ -0,0 +1,7 @@ +# cw20-hooks + +This is a slight modification of the cw20-base contract that allows the minter +to add or remove hooks that are executed on transfer attempts. Hooks are smart +contracts executed via submessage on both transfer and send events. If a hook +throws an error, the transfer is aborted. A hook is the smart contract address +to execute. diff --git a/contracts/external/cw20-hooks/examples/schema.rs b/contracts/external/cw20-hooks/examples/schema.rs new file mode 100644 index 000000000..b70c66f3d --- /dev/null +++ b/contracts/external/cw20-hooks/examples/schema.rs @@ -0,0 +1,12 @@ +use cosmwasm_schema::write_api; +use cw20_hooks::msg::ExecuteMsg; +use cw20_hooks::msg::InstantiateMsg; +use cw20_hooks::msg::QueryMsg; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + } +} diff --git a/contracts/external/cw20-hooks/schema/cw20-hooks.json b/contracts/external/cw20-hooks/schema/cw20-hooks.json new file mode 100644 index 000000000..674f648c1 --- /dev/null +++ b/contracts/external/cw20-hooks/schema/cw20-hooks.json @@ -0,0 +1,1642 @@ +{ + "contract_name": "cw20-hooks", + "contract_version": "2.4.2", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "decimals", + "initial_balances", + "name", + "symbol" + ], + "properties": { + "decimals": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "initial_balances": { + "type": "array", + "items": { + "$ref": "#/definitions/Cw20Coin" + } + }, + "marketing": { + "anyOf": [ + { + "$ref": "#/definitions/InstantiateMarketingInfo" + }, + { + "type": "null" + } + ] + }, + "mint": { + "anyOf": [ + { + "$ref": "#/definitions/MinterResponse" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "owner": { + "type": [ + "string", + "null" + ] + }, + "symbol": { + "type": "string" + } + }, + "additionalProperties": false, + "definitions": { + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Cw20Coin": { + "type": "object", + "required": [ + "address", + "amount" + ], + "properties": { + "address": { + "type": "string" + }, + "amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + }, + "EmbeddedLogo": { + "description": "This is used to store the logo on the blockchain in an accepted format. Enforce maximum size of 5KB on all variants.", + "oneOf": [ + { + "description": "Store the Logo as an SVG file. The content must conform to the spec at https://en.wikipedia.org/wiki/Scalable_Vector_Graphics (The contract should do some light-weight sanity-check validation)", + "type": "object", + "required": [ + "svg" + ], + "properties": { + "svg": { + "$ref": "#/definitions/Binary" + } + }, + "additionalProperties": false + }, + { + "description": "Store the Logo as a PNG file. This will likely only support up to 64x64 or so within the 5KB limit.", + "type": "object", + "required": [ + "png" + ], + "properties": { + "png": { + "$ref": "#/definitions/Binary" + } + }, + "additionalProperties": false + } + ] + }, + "InstantiateMarketingInfo": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/Logo" + }, + { + "type": "null" + } + ] + }, + "marketing": { + "type": [ + "string", + "null" + ] + }, + "project": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "Logo": { + "description": "This is used for uploading logo data, or setting it in InstantiateData", + "oneOf": [ + { + "description": "A reference to an externally hosted logo. Must be a valid HTTP or HTTPS URL.", + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "Logo content stored on the blockchain. Enforce maximum size of 5KB on all variants", + "type": "object", + "required": [ + "embedded" + ], + "properties": { + "embedded": { + "$ref": "#/definitions/EmbeddedLogo" + } + }, + "additionalProperties": false + } + ] + }, + "MinterResponse": { + "type": "object", + "required": [ + "minter" + ], + "properties": { + "cap": { + "description": "cap is a hard cap on total supply that can be achieved by minting. Note that this refers to total_supply. If None, there is unlimited cap.", + "anyOf": [ + { + "$ref": "#/definitions/Uint128" + }, + { + "type": "null" + } + ] + }, + "minter": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Adds a hook which is called on transfer / send events. Only callable by the minter.", + "type": "object", + "required": [ + "add_hook" + ], + "properties": { + "add_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Removes a hook which is called on transfer / send events. Only callable by the minter.", + "type": "object", + "required": [ + "remove_hook" + ], + "properties": { + "remove_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Transfer is a base message to move tokens to another account without triggering actions", + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "recipient" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "recipient": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Burn is a base message to destroy tokens forever", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "amount" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Send is a base message to transfer tokens to a contract and trigger an action on the receiving contract.", + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "contract", + "msg" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "contract": { + "type": "string" + }, + "msg": { + "$ref": "#/definitions/Binary" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only with \"approval\" extension. Allows spender to access an additional amount tokens from the owner's (env.sender) account. If expires is Some(), overwrites current allowance expiration with this one.", + "type": "object", + "required": [ + "increase_allowance" + ], + "properties": { + "increase_allowance": { + "type": "object", + "required": [ + "amount", + "spender" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "expires": { + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "spender": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only with \"approval\" extension. Lowers the spender's access of tokens from the owner's (env.sender) account by amount. If expires is Some(), overwrites current allowance expiration with this one.", + "type": "object", + "required": [ + "decrease_allowance" + ], + "properties": { + "decrease_allowance": { + "type": "object", + "required": [ + "amount", + "spender" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "expires": { + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "spender": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only with \"approval\" extension. Transfers amount tokens from owner -> recipient if `env.sender` has sufficient pre-approval.", + "type": "object", + "required": [ + "transfer_from" + ], + "properties": { + "transfer_from": { + "type": "object", + "required": [ + "amount", + "owner", + "recipient" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "owner": { + "type": "string" + }, + "recipient": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only with \"approval\" extension. Sends amount tokens from owner -> contract if `env.sender` has sufficient pre-approval.", + "type": "object", + "required": [ + "send_from" + ], + "properties": { + "send_from": { + "type": "object", + "required": [ + "amount", + "contract", + "msg", + "owner" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "contract": { + "type": "string" + }, + "msg": { + "$ref": "#/definitions/Binary" + }, + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only with \"approval\" extension. Destroys tokens forever", + "type": "object", + "required": [ + "burn_from" + ], + "properties": { + "burn_from": { + "type": "object", + "required": [ + "amount", + "owner" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only with the \"mintable\" extension. If authorized, creates amount new tokens and adds to the recipient balance.", + "type": "object", + "required": [ + "mint" + ], + "properties": { + "mint": { + "type": "object", + "required": [ + "amount", + "recipient" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "recipient": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only with the \"mintable\" extension. The current minter may set a new minter. Setting the minter to None will remove the token's minter forever.", + "type": "object", + "required": [ + "update_minter" + ], + "properties": { + "update_minter": { + "type": "object", + "properties": { + "new_minter": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only with the \"marketing\" extension. If authorized, updates marketing metadata. Setting None/null for any of these will leave it unchanged. Setting Some(\"\") will clear this field on the contract storage", + "type": "object", + "required": [ + "update_marketing" + ], + "properties": { + "update_marketing": { + "type": "object", + "properties": { + "description": { + "description": "A longer description of the token and it's utility. Designed for tooltips or such", + "type": [ + "string", + "null" + ] + }, + "marketing": { + "description": "The address (if any) who can update this data structure", + "type": [ + "string", + "null" + ] + }, + "project": { + "description": "A URL pointing to the project behind this token.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "If set as the \"marketing\" role on the contract, upload a new URL, SVG, or PNG for the token", + "type": "object", + "required": [ + "upload_logo" + ], + "properties": { + "upload_logo": { + "$ref": "#/definitions/Logo" + } + }, + "additionalProperties": false + }, + { + "description": "Update the contract's ownership. The `action` to be provided can be either to propose transferring ownership to an account, accept a pending ownership transfer, or renounce the ownership permanently.", + "type": "object", + "required": [ + "update_ownership" + ], + "properties": { + "update_ownership": { + "$ref": "#/definitions/Action" + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Action": { + "description": "Actions that can be taken to alter the contract's ownership", + "oneOf": [ + { + "description": "Propose to transfer the contract's ownership to another account, optionally with an expiry time.\n\nCan only be called by the contract's current owner.\n\nAny existing pending ownership transfer is overwritten.", + "type": "object", + "required": [ + "transfer_ownership" + ], + "properties": { + "transfer_ownership": { + "type": "object", + "required": [ + "new_owner" + ], + "properties": { + "expiry": { + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "new_owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Accept the pending ownership transfer.\n\nCan only be called by the pending owner.", + "type": "string", + "enum": [ + "accept_ownership" + ] + }, + { + "description": "Give up the contract's ownership and the possibility of appointing a new owner.\n\nCan only be invoked by the contract's current owner.\n\nAny existing pending ownership transfer is canceled.", + "type": "string", + "enum": [ + "renounce_ownership" + ] + } + ] + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "EmbeddedLogo": { + "description": "This is used to store the logo on the blockchain in an accepted format. Enforce maximum size of 5KB on all variants.", + "oneOf": [ + { + "description": "Store the Logo as an SVG file. The content must conform to the spec at https://en.wikipedia.org/wiki/Scalable_Vector_Graphics (The contract should do some light-weight sanity-check validation)", + "type": "object", + "required": [ + "svg" + ], + "properties": { + "svg": { + "$ref": "#/definitions/Binary" + } + }, + "additionalProperties": false + }, + { + "description": "Store the Logo as a PNG file. This will likely only support up to 64x64 or so within the 5KB limit.", + "type": "object", + "required": [ + "png" + ], + "properties": { + "png": { + "$ref": "#/definitions/Binary" + } + }, + "additionalProperties": false + } + ] + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Logo": { + "description": "This is used for uploading logo data, or setting it in InstantiateData", + "oneOf": [ + { + "description": "A reference to an externally hosted logo. Must be a valid HTTP or HTTPS URL.", + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "Logo content stored on the blockchain. Enforce maximum size of 5KB on all variants", + "type": "object", + "required": [ + "embedded" + ], + "properties": { + "embedded": { + "$ref": "#/definitions/EmbeddedLogo" + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Shows all registered transfer / send hooks.", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns info about the contract ownership.", + "type": "object", + "required": [ + "ownership" + ], + "properties": { + "ownership": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the current balance of the given address, 0 if unset.", + "type": "object", + "required": [ + "balance" + ], + "properties": { + "balance": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns metadata on the contract - name, decimals, supply, etc.", + "type": "object", + "required": [ + "token_info" + ], + "properties": { + "token_info": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only with \"mintable\" extension. Returns who can mint and the hard cap on maximum tokens after minting.", + "type": "object", + "required": [ + "minter" + ], + "properties": { + "minter": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only with \"allowance\" extension. Returns how much spender can use from owner account, 0 if unset.", + "type": "object", + "required": [ + "allowance" + ], + "properties": { + "allowance": { + "type": "object", + "required": [ + "owner", + "spender" + ], + "properties": { + "owner": { + "type": "string" + }, + "spender": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only with \"enumerable\" extension (and \"allowances\") Returns all allowances this owner has approved. Supports pagination.", + "type": "object", + "required": [ + "all_allowances" + ], + "properties": { + "all_allowances": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "owner": { + "type": "string" + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only with \"enumerable\" extension (and \"allowances\") Returns all allowances this spender has been granted. Supports pagination.", + "type": "object", + "required": [ + "all_spender_allowances" + ], + "properties": { + "all_spender_allowances": { + "type": "object", + "required": [ + "spender" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "spender": { + "type": "string" + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only with \"enumerable\" extension Returns all accounts that have balances. Supports pagination.", + "type": "object", + "required": [ + "all_accounts" + ], + "properties": { + "all_accounts": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only with \"marketing\" extension Returns more metadata on the contract to display in the client: - description, logo, project url, etc.", + "type": "object", + "required": [ + "marketing_info" + ], + "properties": { + "marketing_info": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Only with \"marketing\" extension Downloads the embedded logo data (if stored on chain). Errors if no logo data is stored for this contract.", + "type": "object", + "required": [ + "download_logo" + ], + "properties": { + "download_logo": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": null, + "sudo": null, + "responses": { + "all_accounts": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllAccountsResponse", + "type": "object", + "required": [ + "accounts" + ], + "properties": { + "accounts": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "all_allowances": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllAllowancesResponse", + "type": "object", + "required": [ + "allowances" + ], + "properties": { + "allowances": { + "type": "array", + "items": { + "$ref": "#/definitions/AllowanceInfo" + } + } + }, + "definitions": { + "AllowanceInfo": { + "type": "object", + "required": [ + "allowance", + "expires", + "spender" + ], + "properties": { + "allowance": { + "$ref": "#/definitions/Uint128" + }, + "expires": { + "$ref": "#/definitions/Expiration" + }, + "spender": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "all_spender_allowances": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllSpenderAllowancesResponse", + "type": "object", + "required": [ + "allowances" + ], + "properties": { + "allowances": { + "type": "array", + "items": { + "$ref": "#/definitions/SpenderAllowanceInfo" + } + } + }, + "definitions": { + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "SpenderAllowanceInfo": { + "type": "object", + "required": [ + "allowance", + "expires", + "owner" + ], + "properties": { + "allowance": { + "$ref": "#/definitions/Uint128" + }, + "expires": { + "$ref": "#/definitions/Expiration" + }, + "owner": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "allowance": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllowanceResponse", + "type": "object", + "required": [ + "allowance", + "expires" + ], + "properties": { + "allowance": { + "$ref": "#/definitions/Uint128" + }, + "expires": { + "$ref": "#/definitions/Expiration" + } + }, + "definitions": { + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "balance": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "BalanceResponse", + "type": "object", + "required": [ + "balance" + ], + "properties": { + "balance": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false, + "definitions": { + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "download_logo": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DownloadLogoResponse", + "description": "When we download an embedded logo, we get this response type. We expect a SPA to be able to accept this info and display it.", + "type": "object", + "required": [ + "data", + "mime_type" + ], + "properties": { + "data": { + "$ref": "#/definitions/Binary" + }, + "mime_type": { + "type": "string" + } + }, + "additionalProperties": false, + "definitions": { + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + } + } + }, + "hooks": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HooksResponse", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "marketing_info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MarketingInfoResponse", + "type": "object", + "properties": { + "description": { + "description": "A longer description of the token and it's utility. Designed for tooltips or such", + "type": [ + "string", + "null" + ] + }, + "logo": { + "description": "A link to the logo, or a comment there is an on-chain logo stored", + "anyOf": [ + { + "$ref": "#/definitions/LogoInfo" + }, + { + "type": "null" + } + ] + }, + "marketing": { + "description": "The address (if any) who can update this data structure", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, + "project": { + "description": "A URL pointing to the project behind this token.", + "type": [ + "string", + "null" + ] + } + }, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "LogoInfo": { + "description": "This is used to display logo info, provide a link or inform there is one that can be downloaded from the blockchain itself", + "oneOf": [ + { + "description": "A reference to an externally hosted logo. Must be a valid HTTP or HTTPS URL.", + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "description": "There is an embedded logo on the chain, make another call to download it.", + "type": "string", + "enum": [ + "embedded" + ] + } + ] + } + } + }, + "minter": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MinterResponse", + "type": "object", + "required": [ + "minter" + ], + "properties": { + "cap": { + "description": "cap is a hard cap on total supply that can be achieved by minting. Note that this refers to total_supply. If None, there is unlimited cap.", + "anyOf": [ + { + "$ref": "#/definitions/Uint128" + }, + { + "type": "null" + } + ] + }, + "minter": { + "type": "string" + } + }, + "additionalProperties": false, + "definitions": { + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + }, + "ownership": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Ownership_for_Addr", + "description": "The contract's ownership info", + "type": "object", + "properties": { + "owner": { + "description": "The contract's current owner. `None` if the ownership has been renounced.", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, + "pending_expiry": { + "description": "The deadline for the pending owner to accept the ownership. `None` if there isn't a pending ownership transfer, or if a transfer exists and it doesn't have a deadline.", + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "pending_owner": { + "description": "The account who has been proposed to take over the ownership. `None` if there isn't a pending ownership transfer.", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "token_info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TokenInfoResponse", + "type": "object", + "required": [ + "decimals", + "name", + "symbol", + "total_supply" + ], + "properties": { + "decimals": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "name": { + "type": "string" + }, + "symbol": { + "type": "string" + }, + "total_supply": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false, + "definitions": { + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } + } + } +} diff --git a/contracts/external/cw20-hooks/src/contract.rs b/contracts/external/cw20-hooks/src/contract.rs new file mode 100644 index 000000000..9a5ad701e --- /dev/null +++ b/contracts/external/cw20-hooks/src/contract.rs @@ -0,0 +1,723 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + attr, to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Order, Reply, Response, + StdError, StdResult, SubMsg, Uint128, WasmMsg, +}; + +use cw2::{ensure_from_older_version, get_contract_version, set_contract_version}; +use cw20::{Cw20ReceiveMsg, EmbeddedLogo, Logo, LogoInfo}; +use cw20_base::allowances::deduct_allowance; +use cw20_base::msg::InstantiateMsg as Cw20InstantiateMsg; +use cw20_base::state::{ + MinterData, ALLOWANCES, ALLOWANCES_SPENDER, BALANCES, LOGO, MARKETING_INFO, TOKEN_INFO, +}; + +use crate::error::ContractError; +use crate::hooks::{Cw20HookExecuteMsg, Cw20HookMsg}; +use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; +use crate::state::{CAP, HOOKS}; + +// Version info, for migration info +const CONTRACT_NAME: &str = "crates.io:cw20-hooks"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +const HOOK_REPLY_ID: u64 = 0; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + // Call cw20-base instantiate to set everything up. + cw20_base::contract::instantiate( + deps.branch(), + env, + info, + Cw20InstantiateMsg { + name: msg.name, + symbol: msg.symbol, + decimals: msg.decimals, + initial_balances: msg.initial_balances, + mint: msg.mint.clone(), + marketing: msg.marketing, + }, + )?; + + // cw20-base::contract::instantiate sets the contract version, so overwrite + // it here. + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + // Initialize owner. + cw_ownable::initialize_owner(deps.storage, deps.api, msg.owner.as_deref())?; + + // Initialize cap. + CAP.save(deps.storage, &msg.mint.and_then(|m| m.cap))?; + + Ok(Response::default()) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + // NEW VARIANTS FOR CW20-HOOKS + ExecuteMsg::AddHook { addr } => execute_add_hook(deps, info, addr), + ExecuteMsg::RemoveHook { addr } => execute_remove_hook(deps, info, addr), + ExecuteMsg::UpdateOwnership(action) => execute_update_owner(deps, info, env, action), + + // COPIED FROM CW20-BASE + ExecuteMsg::Transfer { recipient, amount } => { + Ok(execute_transfer(deps, env, info, recipient, amount)?) + } + ExecuteMsg::Burn { amount } => { + Ok(cw20_base::contract::execute_burn(deps, env, info, amount)?) + } + ExecuteMsg::Send { + contract, + amount, + msg, + } => Ok(execute_send(deps, env, info, contract, amount, msg)?), + ExecuteMsg::Mint { recipient, amount } => Ok(cw20_base::contract::execute_mint( + deps, env, info, recipient, amount, + )?), + ExecuteMsg::IncreaseAllowance { + spender, + amount, + expires, + } => Ok(cw20_base::allowances::execute_increase_allowance( + deps, env, info, spender, amount, expires, + )?), + ExecuteMsg::DecreaseAllowance { + spender, + amount, + expires, + } => Ok(cw20_base::allowances::execute_decrease_allowance( + deps, env, info, spender, amount, expires, + )?), + ExecuteMsg::TransferFrom { + owner, + recipient, + amount, + } => Ok(execute_transfer_from( + deps, env, info, owner, recipient, amount, + )?), + ExecuteMsg::BurnFrom { owner, amount } => Ok(cw20_base::allowances::execute_burn_from( + deps, env, info, owner, amount, + )?), + ExecuteMsg::SendFrom { + owner, + contract, + amount, + msg, + } => Ok(execute_send_from( + deps, env, info, owner, contract, amount, msg, + )?), + ExecuteMsg::UpdateMarketing { + project, + description, + marketing, + } => Ok(execute_update_marketing( + deps, + env, + info, + project, + description, + marketing, + )?), + ExecuteMsg::UploadLogo(logo) => Ok(execute_upload_logo(deps, env, info, logo)?), + ExecuteMsg::UpdateMinter { new_minter } => { + execute_update_minter(deps, env, info, new_minter) + } + } +} + +pub fn execute_update_owner( + deps: DepsMut, + info: MessageInfo, + env: Env, + action: cw_ownable::Action, +) -> Result { + let ownership = cw_ownable::update_ownership(deps, &env.block, &info.sender, action)?; + Ok(Response::default().add_attributes(ownership.into_attributes())) +} + +pub fn execute_add_hook( + deps: DepsMut, + info: MessageInfo, + addr: String, +) -> Result { + // Check that the sender is the owner. + let ownership = cw_ownable::get_ownership(deps.storage)?; + if ownership.owner.map_or(true, |owner| owner != info.sender) { + return Err(ContractError::Unauthorized {}); + } + + let hook = deps.api.addr_validate(&addr)?; + HOOKS.add_hook(deps.storage, hook.clone())?; + + Ok(Response::new() + .add_attribute("action", "add_hook") + .add_attribute("hook", hook)) +} + +pub fn execute_remove_hook( + deps: DepsMut, + info: MessageInfo, + addr: String, +) -> Result { + // Check that the sender is the owner. + let ownership = cw_ownable::get_ownership(deps.storage)?; + if ownership.owner.map_or(true, |owner| owner != info.sender) { + return Err(ContractError::Unauthorized {}); + } + + let hook = deps.api.addr_validate(&addr)?; + HOOKS.remove_hook(deps.storage, hook.clone())?; + + Ok(Response::new() + .add_attribute("action", "remove_hook") + .add_attribute("hook", hook)) +} + +// Copied from cw20-base and modified to add hooks. +pub fn execute_transfer( + deps: DepsMut, + _env: Env, + info: MessageInfo, + recipient: String, + amount: Uint128, +) -> Result { + let rcpt_addr = deps.api.addr_validate(&recipient)?; + + BALANCES.update( + deps.storage, + &info.sender, + |balance: Option| -> StdResult<_> { + Ok(balance.unwrap_or_default().checked_sub(amount)?) + }, + )?; + BALANCES.update( + deps.storage, + &rcpt_addr, + |balance: Option| -> StdResult<_> { Ok(balance.unwrap_or_default() + amount) }, + )?; + + // Add hooks. + let hooks = HOOKS.prepare_hooks(deps.storage, |h| { + Ok(SubMsg::reply_on_error( + WasmMsg::Execute { + contract_addr: h.to_string(), + msg: to_json_binary(&Cw20HookExecuteMsg::Cw20Hook(Cw20HookMsg::Transfer { + sender: info.sender.to_string(), + recipient: recipient.clone(), + amount, + }))?, + funds: vec![], + }, + HOOK_REPLY_ID, + )) + })?; + + let res = Response::new() + .add_attribute("action", "transfer") + .add_attribute("from", info.sender) + .add_attribute("to", recipient) + .add_attribute("amount", amount) + .add_submessages(hooks); + Ok(res) +} + +// Copied from cw20-base and modified to add hooks. +pub fn execute_send( + deps: DepsMut, + _env: Env, + info: MessageInfo, + contract: String, + amount: Uint128, + msg: Binary, +) -> Result { + let rcpt_addr = deps.api.addr_validate(&contract)?; + + // move the tokens to the contract + BALANCES.update( + deps.storage, + &info.sender, + |balance: Option| -> StdResult<_> { + Ok(balance.unwrap_or_default().checked_sub(amount)?) + }, + )?; + BALANCES.update( + deps.storage, + &rcpt_addr, + |balance: Option| -> StdResult<_> { Ok(balance.unwrap_or_default() + amount) }, + )?; + + // Add hooks. + let hooks = HOOKS.prepare_hooks(deps.storage, |h| { + Ok(SubMsg::reply_on_error( + WasmMsg::Execute { + contract_addr: h.to_string(), + msg: to_json_binary(&Cw20HookExecuteMsg::Cw20Hook(Cw20HookMsg::Send { + sender: info.sender.to_string(), + contract: contract.clone(), + amount, + msg: msg.clone(), + }))?, + funds: vec![], + }, + HOOK_REPLY_ID, + )) + })?; + + let res = Response::new() + .add_attribute("action", "send") + .add_attribute("from", &info.sender) + .add_attribute("to", &contract) + .add_attribute("amount", amount) + .add_message( + Cw20ReceiveMsg { + sender: info.sender.into(), + amount, + msg, + } + .into_cosmos_msg(contract)?, + ) + .add_submessages(hooks); + Ok(res) +} + +// Copied from cw20-base and modified to add hooks. +pub fn execute_transfer_from( + deps: DepsMut, + env: Env, + info: MessageInfo, + owner: String, + recipient: String, + amount: Uint128, +) -> Result { + let rcpt_addr = deps.api.addr_validate(&recipient)?; + let owner_addr = deps.api.addr_validate(&owner)?; + + // deduct allowance before doing anything else have enough allowance + deduct_allowance(deps.storage, &owner_addr, &info.sender, &env.block, amount)?; + + BALANCES.update( + deps.storage, + &owner_addr, + |balance: Option| -> StdResult<_> { + Ok(balance.unwrap_or_default().checked_sub(amount)?) + }, + )?; + BALANCES.update( + deps.storage, + &rcpt_addr, + |balance: Option| -> StdResult<_> { Ok(balance.unwrap_or_default() + amount) }, + )?; + + // Add hooks. + let hooks = HOOKS.prepare_hooks(deps.storage, |h| { + Ok(SubMsg::reply_on_error( + WasmMsg::Execute { + contract_addr: h.to_string(), + msg: to_json_binary(&Cw20HookExecuteMsg::Cw20Hook(Cw20HookMsg::Transfer { + sender: info.sender.to_string(), + recipient: recipient.clone(), + amount, + }))?, + funds: vec![], + }, + HOOK_REPLY_ID, + )) + })?; + + let res = Response::new() + .add_attributes(vec![ + attr("action", "transfer_from"), + attr("from", owner), + attr("to", recipient), + attr("by", info.sender), + attr("amount", amount), + ]) + .add_submessages(hooks); + Ok(res) +} + +// Copied from cw20-base and modified to add hooks. +pub fn execute_send_from( + deps: DepsMut, + env: Env, + info: MessageInfo, + owner: String, + contract: String, + amount: Uint128, + msg: Binary, +) -> Result { + let rcpt_addr = deps.api.addr_validate(&contract)?; + let owner_addr = deps.api.addr_validate(&owner)?; + + // deduct allowance before doing anything else have enough allowance + deduct_allowance(deps.storage, &owner_addr, &info.sender, &env.block, amount)?; + + // move the tokens to the contract + BALANCES.update( + deps.storage, + &owner_addr, + |balance: Option| -> StdResult<_> { + Ok(balance.unwrap_or_default().checked_sub(amount)?) + }, + )?; + BALANCES.update( + deps.storage, + &rcpt_addr, + |balance: Option| -> StdResult<_> { Ok(balance.unwrap_or_default() + amount) }, + )?; + + let attrs = vec![ + attr("action", "send_from"), + attr("from", &owner), + attr("to", &contract), + attr("by", &info.sender), + attr("amount", amount), + ]; + + // Add hooks. + let hooks = HOOKS.prepare_hooks(deps.storage, |h| { + Ok(SubMsg::reply_on_error( + WasmMsg::Execute { + contract_addr: h.to_string(), + msg: to_json_binary(&Cw20HookExecuteMsg::Cw20Hook(Cw20HookMsg::Send { + sender: info.sender.to_string(), + contract: contract.clone(), + amount, + msg: msg.clone(), + }))?, + funds: vec![], + }, + HOOK_REPLY_ID, + )) + })?; + + // create a send message + let msg = Cw20ReceiveMsg { + sender: info.sender.into(), + amount, + msg, + } + .into_cosmos_msg(contract)?; + + let res = Response::new() + .add_message(msg) + .add_attributes(attrs) + .add_submessages(hooks); + Ok(res) +} + +// Copied from cw20-base and modified to allow only the owner to modify minter. +pub fn execute_update_minter( + deps: DepsMut, + _env: Env, + info: MessageInfo, + new_minter: Option, +) -> Result { + // Check that the sender is the owner. + let ownership = cw_ownable::get_ownership(deps.storage)?; + if ownership.owner.map_or(true, |owner| owner != info.sender) { + return Err(ContractError::Unauthorized {}); + } + + let mut config = TOKEN_INFO.load(deps.storage)?; + + let minter_data: Option = new_minter + .map(|new_minter| deps.api.addr_validate(&new_minter)) + .transpose()? + .map(|minter| { + Ok::(MinterData { + minter, + // Load cap from storage item. + cap: CAP.load(deps.storage)?, + }) + }) + .transpose()?; + + config.mint = minter_data; + + TOKEN_INFO.save(deps.storage, &config)?; + + Ok(Response::default() + .add_attribute("action", "update_minter") + .add_attribute( + "new_minter", + config + .mint + .map(|m| m.minter.into_string()) + .unwrap_or_else(|| "None".to_string()), + )) +} + +// Copied from cw20-base and modified to allow owner to modify marketing info as +// well as the marketing address. +pub fn execute_update_marketing( + deps: DepsMut, + _env: Env, + info: MessageInfo, + project: Option, + description: Option, + marketing: Option, +) -> Result { + let mut marketing_info = MARKETING_INFO.may_load(deps.storage)?.unwrap_or_default(); + + // Check sender is owner or marketer. + let ownership = cw_ownable::get_ownership(deps.storage)?; + let is_owner = ownership.owner.map_or(false, |owner| owner == info.sender); + let is_marketer = marketing_info + .marketing + .as_ref() + .map_or(false, |m| *m == info.sender); + if !is_owner && !is_marketer { + return Err(ContractError::Unauthorized {}); + } + + match project { + Some(empty) if empty.trim().is_empty() => marketing_info.project = None, + Some(project) => marketing_info.project = Some(project), + None => (), + } + + match description { + Some(empty) if empty.trim().is_empty() => marketing_info.description = None, + Some(description) => marketing_info.description = Some(description), + None => (), + } + + match marketing { + Some(empty) if empty.trim().is_empty() => marketing_info.marketing = None, + Some(marketing) => marketing_info.marketing = Some(deps.api.addr_validate(&marketing)?), + None => (), + } + + if marketing_info.project.is_none() + && marketing_info.description.is_none() + && marketing_info.marketing.is_none() + && marketing_info.logo.is_none() + { + MARKETING_INFO.remove(deps.storage); + } else { + MARKETING_INFO.save(deps.storage, &marketing_info)?; + } + + let res = Response::new().add_attribute("action", "update_marketing"); + Ok(res) +} + +// Copied from cw20-base and modified to allow owner to modify marketing info as +// well as the marketing address. +pub fn execute_upload_logo( + deps: DepsMut, + _env: Env, + info: MessageInfo, + logo: Logo, +) -> Result { + let mut marketing_info = MARKETING_INFO.may_load(deps.storage)?.unwrap_or_default(); + + verify_logo(&logo)?; + + // Check sender is owner or marketer. + let ownership = cw_ownable::get_ownership(deps.storage)?; + let is_owner = ownership.owner.map_or(false, |owner| owner == info.sender); + let is_marketer = marketing_info + .marketing + .as_ref() + .map_or(false, |m| *m == info.sender); + if !is_owner && !is_marketer { + return Err(ContractError::Unauthorized {}); + } + + LOGO.save(deps.storage, &logo)?; + + let logo_info = match logo { + Logo::Url(url) => LogoInfo::Url(url), + Logo::Embedded(_) => LogoInfo::Embedded, + }; + + marketing_info.logo = Some(logo_info); + MARKETING_INFO.save(deps.storage, &marketing_info)?; + + let res = Response::new().add_attribute("action", "upload_logo"); + Ok(res) +} + +const LOGO_SIZE_CAP: usize = 5 * 1024; + +/// Checks if data starts with XML preamble +fn verify_xml_preamble(data: &[u8]) -> Result<(), ContractError> { + // The easiest way to perform this check would be just match on regex, however regex + // compilation is heavy and probably not worth it. + + let preamble = data + .split_inclusive(|c| *c == b'>') + .next() + .ok_or(ContractError::Cw20( + cw20_base::ContractError::InvalidXmlPreamble {}, + ))?; + + const PREFIX: &[u8] = b""; + + if !(preamble.starts_with(PREFIX) && preamble.ends_with(POSTFIX)) { + Err(ContractError::Cw20( + cw20_base::ContractError::InvalidXmlPreamble {}, + )) + } else { + Ok(()) + } + + // Additionally attributes format could be validated as they are well defined, as well as + // comments presence inside of preable, but it is probably not worth it. +} + +/// Validates XML logo +fn verify_xml_logo(logo: &[u8]) -> Result<(), ContractError> { + verify_xml_preamble(logo)?; + + if logo.len() > LOGO_SIZE_CAP { + Err(ContractError::Cw20(cw20_base::ContractError::LogoTooBig {})) + } else { + Ok(()) + } +} + +/// Validates png logo +fn verify_png_logo(logo: &[u8]) -> Result<(), ContractError> { + // PNG header format: + // 0x89 - magic byte, out of ASCII table to fail on 7-bit systems + // "PNG" ascii representation + // [0x0d, 0x0a] - dos style line ending + // 0x1a - dos control character, stop displaying rest of the file + // 0x0a - unix style line ending + const HEADER: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; + if logo.len() > LOGO_SIZE_CAP { + Err(ContractError::Cw20(cw20_base::ContractError::LogoTooBig {})) + } else if !logo.starts_with(&HEADER) { + Err(ContractError::Cw20( + cw20_base::ContractError::InvalidPngHeader {}, + )) + } else { + Ok(()) + } +} + +/// Checks if passed logo is correct, and if not, returns an error +fn verify_logo(logo: &Logo) -> Result<(), ContractError> { + match logo { + Logo::Embedded(EmbeddedLogo::Svg(logo)) => verify_xml_logo(logo), + Logo::Embedded(EmbeddedLogo::Png(logo)) => verify_png_logo(logo), + Logo::Url(_) => Ok(()), // Any reasonable url validation would be regex based, probably not worth it + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + // NEW VARIANTS FOR CW20-HOOKS + QueryMsg::Hooks {} => to_json_binary(&HOOKS.query_hooks(deps)?), + QueryMsg::Ownership {} => to_json_binary(&cw_ownable::get_ownership(deps.storage)?), + + // COPIED FROM CW20-BASE + QueryMsg::Balance { address } => { + to_json_binary(&cw20_base::contract::query_balance(deps, address)?) + } + QueryMsg::TokenInfo {} => to_json_binary(&cw20_base::contract::query_token_info(deps)?), + QueryMsg::Minter {} => to_json_binary(&cw20_base::contract::query_minter(deps)?), + QueryMsg::Allowance { owner, spender } => to_json_binary( + &cw20_base::allowances::query_allowance(deps, owner, spender)?, + ), + QueryMsg::AllAllowances { + owner, + start_after, + limit, + } => to_json_binary(&cw20_base::enumerable::query_owner_allowances( + deps, + owner, + start_after, + limit, + )?), + QueryMsg::AllSpenderAllowances { + spender, + start_after, + limit, + } => to_json_binary(&cw20_base::enumerable::query_spender_allowances( + deps, + spender, + start_after, + limit, + )?), + QueryMsg::AllAccounts { start_after, limit } => to_json_binary( + &cw20_base::enumerable::query_all_accounts(deps, start_after, limit)?, + ), + QueryMsg::MarketingInfo {} => { + to_json_binary(&cw20_base::contract::query_marketing_info(deps)?) + } + QueryMsg::DownloadLogo {} => { + to_json_binary(&cw20_base::contract::query_download_logo(deps)?) + } + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(_deps: DepsMut, _env: Env, msg: Reply) -> Result { + match msg.id { + HOOK_REPLY_ID => { + // Error if hook execution fails, rolling back previous changes. + msg.result + .into_result() + .map_err(|error| ContractError::HookErrored { error })?; + + Ok(Response::default()) + } + _ => Err(ContractError::UnknownReplyId { id: msg.id }), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> Result { + match msg { + MigrateMsg::FromBase { owner } => { + // Validate safe to migrate. + let stored = get_contract_version(deps.storage)?; + if stored.contract != "crates.io:cw20-base" { + return Err(ContractError::InvalidMigration { + expected: "crates.io:cw20-base".to_string(), + actual: stored.contract, + }); + } + + // Update contract version. + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + // Initialize owner. + cw_ownable::initialize_owner(deps.storage, deps.api, Some(&owner))?; + + // Copied from cw20-base v1.1.2. + let original_version = + ensure_from_older_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + if original_version < "0.14.0".parse::().unwrap() { + // Build reverse map of allowances per spender + let data = ALLOWANCES + .range(deps.storage, None, None, Order::Ascending) + .collect::>>()?; + for ((owner, spender), allowance) in data { + ALLOWANCES_SPENDER.save(deps.storage, (&spender, &owner), &allowance)?; + } + } + + Ok(Response::default()) + } + } +} diff --git a/contracts/external/cw20-hooks/src/error.rs b/contracts/external/cw20-hooks/src/error.rs new file mode 100644 index 000000000..209f51a8d --- /dev/null +++ b/contracts/external/cw20-hooks/src/error.rs @@ -0,0 +1,29 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error(transparent)] + Cw20(#[from] cw20_base::ContractError), + + #[error(transparent)] + Ownable(#[from] cw_ownable::OwnershipError), + + #[error(transparent)] + HookError(#[from] cw_controllers::HookError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Got a submessage reply with unknown ID: {id}")] + UnknownReplyId { id: u64 }, + + #[error("Hook errored: {error}")] + HookErrored { error: String }, + + #[error("Invalid migration. Expected contract {expected}, got {actual}")] + InvalidMigration { expected: String, actual: String }, +} diff --git a/contracts/external/cw20-hooks/src/hooks.rs b/contracts/external/cw20-hooks/src/hooks.rs new file mode 100644 index 000000000..47db48a2d --- /dev/null +++ b/contracts/external/cw20-hooks/src/hooks.rs @@ -0,0 +1,22 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Binary, Uint128}; + +#[cw_serde] +pub enum Cw20HookMsg { + Transfer { + sender: String, + recipient: String, + amount: Uint128, + }, + Send { + sender: String, + amount: Uint128, + contract: String, + msg: Binary, + }, +} + +#[cw_serde] +pub enum Cw20HookExecuteMsg { + Cw20Hook(Cw20HookMsg), +} diff --git a/contracts/external/cw20-hooks/src/lib.rs b/contracts/external/cw20-hooks/src/lib.rs new file mode 100644 index 000000000..f2df01179 --- /dev/null +++ b/contracts/external/cw20-hooks/src/lib.rs @@ -0,0 +1,13 @@ +pub mod contract; +mod error; +pub mod hooks; +pub mod msg; +pub mod state; + +pub use crate::error::ContractError; + +#[cfg(test)] +mod tests_cw20_base; + +#[cfg(test)] +mod tests; diff --git a/contracts/external/cw20-hooks/src/msg.rs b/contracts/external/cw20-hooks/src/msg.rs new file mode 100644 index 000000000..e0821d713 --- /dev/null +++ b/contracts/external/cw20-hooks/src/msg.rs @@ -0,0 +1,167 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; + +use cosmwasm_std::{Addr, Binary, Uint128}; + +use cw20::{Cw20Coin, MinterResponse}; +use cw_ownable::cw_ownable_execute; + +pub use cw20_base::msg::InstantiateMarketingInfo; + +#[cw_serde] +pub struct InstantiateMsg { + // NEW FIELDS FOR CW20-HOOKS + pub owner: Option, + + // COPIED FROM CW20-BASE + pub name: String, + pub symbol: String, + pub decimals: u8, + pub initial_balances: Vec, + pub mint: Option, + pub marketing: Option, +} + +#[cw_ownable_execute] +#[cw_serde] +pub enum ExecuteMsg { + // NEW VARIANTS FOR CW20-HOOKS + /// Adds a hook which is called on transfer / send events. + /// Only callable by the minter. + AddHook { addr: String }, + /// Removes a hook which is called on transfer / send events. + /// Only callable by the minter. + RemoveHook { addr: String }, + + // COPIED FROM CW20-BASE + /// Transfer is a base message to move tokens to another account without triggering actions + Transfer { recipient: String, amount: Uint128 }, + /// Burn is a base message to destroy tokens forever + Burn { amount: Uint128 }, + /// Send is a base message to transfer tokens to a contract and trigger an action + /// on the receiving contract. + Send { + contract: String, + amount: Uint128, + msg: Binary, + }, + /// Only with "approval" extension. Allows spender to access an additional amount tokens + /// from the owner's (env.sender) account. If expires is Some(), overwrites current allowance + /// expiration with this one. + IncreaseAllowance { + spender: String, + amount: Uint128, + expires: Option, + }, + /// Only with "approval" extension. Lowers the spender's access of tokens + /// from the owner's (env.sender) account by amount. If expires is Some(), overwrites current + /// allowance expiration with this one. + DecreaseAllowance { + spender: String, + amount: Uint128, + expires: Option, + }, + /// Only with "approval" extension. Transfers amount tokens from owner -> recipient + /// if `env.sender` has sufficient pre-approval. + TransferFrom { + owner: String, + recipient: String, + amount: Uint128, + }, + /// Only with "approval" extension. Sends amount tokens from owner -> contract + /// if `env.sender` has sufficient pre-approval. + SendFrom { + owner: String, + contract: String, + amount: Uint128, + msg: Binary, + }, + /// Only with "approval" extension. Destroys tokens forever + BurnFrom { owner: String, amount: Uint128 }, + /// Only with the "mintable" extension. If authorized, creates amount new tokens + /// and adds to the recipient balance. + Mint { recipient: String, amount: Uint128 }, + /// Only with the "mintable" extension. The current minter may set + /// a new minter. Setting the minter to None will remove the + /// token's minter forever. + UpdateMinter { new_minter: Option }, + /// Only with the "marketing" extension. If authorized, updates marketing metadata. + /// Setting None/null for any of these will leave it unchanged. + /// Setting Some("") will clear this field on the contract storage + UpdateMarketing { + /// A URL pointing to the project behind this token. + project: Option, + /// A longer description of the token and it's utility. Designed for tooltips or such + description: Option, + /// The address (if any) who can update this data structure + marketing: Option, + }, + /// If set as the "marketing" role on the contract, upload a new URL, SVG, or PNG for the token + UploadLogo(cw20::Logo), +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + // NEW VARIANTS FOR CW20-HOOKS + /// Shows all registered transfer / send hooks. + #[returns(cw_controllers::HooksResponse)] + Hooks {}, + /// Returns info about the contract ownership. + #[returns(cw_ownable::Ownership)] + Ownership {}, + + // COPIED FROM CW20-BASE + /// Returns the current balance of the given address, 0 if unset. + #[returns(cw20::BalanceResponse)] + Balance { address: String }, + /// Returns metadata on the contract - name, decimals, supply, etc. + #[returns(cw20::TokenInfoResponse)] + TokenInfo {}, + /// Only with "mintable" extension. + /// Returns who can mint and the hard cap on maximum tokens after minting. + #[returns(cw20::MinterResponse)] + Minter {}, + /// Only with "allowance" extension. + /// Returns how much spender can use from owner account, 0 if unset. + #[returns(cw20::AllowanceResponse)] + Allowance { owner: String, spender: String }, + /// Only with "enumerable" extension (and "allowances") + /// Returns all allowances this owner has approved. Supports pagination. + #[returns(cw20::AllAllowancesResponse)] + AllAllowances { + owner: String, + start_after: Option, + limit: Option, + }, + /// Only with "enumerable" extension (and "allowances") + /// Returns all allowances this spender has been granted. Supports pagination. + #[returns(cw20::AllSpenderAllowancesResponse)] + AllSpenderAllowances { + spender: String, + start_after: Option, + limit: Option, + }, + /// Only with "enumerable" extension + /// Returns all accounts that have balances. Supports pagination. + #[returns(cw20::AllAccountsResponse)] + AllAccounts { + start_after: Option, + limit: Option, + }, + /// Only with "marketing" extension + /// Returns more metadata on the contract to display in the client: + /// - description, logo, project url, etc. + #[returns(cw20::MarketingInfoResponse)] + MarketingInfo {}, + /// Only with "marketing" extension + /// Downloads the embedded logo data (if stored on chain). Errors if no logo data is stored for this + /// contract. + #[returns(cw20::DownloadLogoResponse)] + DownloadLogo {}, +} + +#[cw_serde] +pub enum MigrateMsg { + /// Migrate from cw20-base. + FromBase { owner: String }, +} diff --git a/contracts/external/cw20-hooks/src/state.rs b/contracts/external/cw20-hooks/src/state.rs new file mode 100644 index 000000000..71b7f53fe --- /dev/null +++ b/contracts/external/cw20-hooks/src/state.rs @@ -0,0 +1,11 @@ +use cosmwasm_std::Uint128; +use cw_controllers::Hooks; +use cw_storage_plus::Item; + +// Transfer/send contract hooks. +pub const HOOKS: Hooks = Hooks::new("hooks"); + +// Total supply cap set on instantiate if minting is allowed. If the owner +// decides to clear the minter and then add a minter later, this cap is restored +// to ensure the cap is preserved even when the minter is removed. +pub const CAP: Item> = Item::new("cap"); diff --git a/contracts/external/cw20-hooks/src/tests.rs b/contracts/external/cw20-hooks/src/tests.rs new file mode 100644 index 000000000..b6cdf3563 --- /dev/null +++ b/contracts/external/cw20-hooks/src/tests.rs @@ -0,0 +1,468 @@ +use cosmwasm_std::{Addr, Empty, Uint128}; +use cw20::{Cw20Coin, MinterResponse}; +use cw_controllers::HooksResponse; +use cw_multi_test::{App, Contract, ContractWrapper, Executor}; +use cw_ownable::Ownership; + +use crate::{ + msg::{ExecuteMsg, InstantiateMsg, QueryMsg}, + ContractError, +}; + +fn cw20_hooks_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_reply(crate::contract::reply) + .with_migrate(crate::contract::migrate); + Box::new(contract) +} + +const OWNER: &str = "owner"; +const ADDR2: &str = "addr2"; +const ADDR3: &str = "addr3"; +const NOONE: &str = "noone"; + +fn setup_contract(app: &mut App) -> Addr { + let cw20_hooks_code_id = app.store_code(cw20_hooks_contract()); + + let initial_balances = vec![ + Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: ADDR2.to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: ADDR3.to_string(), + amount: Uint128::new(100_000_000), + }, + ]; + + // Instantiate cw20-hooks contract. + let msg = InstantiateMsg { + owner: Some(OWNER.to_string()), + name: "name".to_string(), + symbol: "symbol".to_string(), + decimals: 6, + initial_balances, + mint: Some(MinterResponse { + minter: OWNER.to_string(), + cap: Some(Uint128::new(500_000_000)), + }), + marketing: None, + }; + + app.instantiate_contract( + cw20_hooks_code_id, + Addr::unchecked(OWNER), + &msg, + &[], + "cw20-hooks", + None, + ) + .unwrap() +} + +#[test] +pub fn test_instantiate() { + let mut app = App::default(); + setup_contract(&mut app); +} + +#[test] +pub fn test_add_remove_hook() { + let mut app = App::default(); + let cw20_hooks_addr = setup_contract(&mut app); + + // Ensure no hooks have been registered. + let hooks: HooksResponse = app + .wrap() + .query_wasm_smart(cw20_hooks_addr.clone(), &QueryMsg::Hooks {}) + .unwrap(); + assert_eq!(hooks, HooksResponse { hooks: vec![] }); + + // Fail to add if not owner. + let err: ContractError = app + .execute_contract( + Addr::unchecked(NOONE), + cw20_hooks_addr.clone(), + &ExecuteMsg::AddHook { + addr: ADDR2.to_string(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized {}); + + // Add successfully if owner. + app.execute_contract( + Addr::unchecked(OWNER), + cw20_hooks_addr.clone(), + &ExecuteMsg::AddHook { + addr: ADDR2.to_string(), + }, + &[], + ) + .unwrap(); + + // Ensure 1 hook registered. + let hooks: HooksResponse = app + .wrap() + .query_wasm_smart(cw20_hooks_addr.clone(), &QueryMsg::Hooks {}) + .unwrap(); + assert_eq!( + hooks, + HooksResponse { + hooks: vec![ADDR2.to_string()] + } + ); + + // Fail to remove if not owner. + let err: ContractError = app + .execute_contract( + Addr::unchecked(NOONE), + cw20_hooks_addr.clone(), + &ExecuteMsg::RemoveHook { + addr: ADDR2.to_string(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized {}); + + // Remove successfully if owner. + app.execute_contract( + Addr::unchecked(OWNER), + cw20_hooks_addr.clone(), + &ExecuteMsg::RemoveHook { + addr: ADDR2.to_string(), + }, + &[], + ) + .unwrap(); + + // Ensure no hooks registered. + let hooks: HooksResponse = app + .wrap() + .query_wasm_smart(cw20_hooks_addr.clone(), &QueryMsg::Hooks {}) + .unwrap(); + assert_eq!(hooks, HooksResponse { hooks: vec![] }); + + // Remove owner. + app.execute_contract( + Addr::unchecked(OWNER), + cw20_hooks_addr.clone(), + &ExecuteMsg::UpdateOwnership(cw_ownable::Action::RenounceOwnership {}), + &[], + ) + .unwrap(); + + // Owner can no longer add nor remove hooks. + let err: ContractError = app + .execute_contract( + Addr::unchecked(OWNER), + cw20_hooks_addr.clone(), + &ExecuteMsg::AddHook { + addr: ADDR2.to_string(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized {}); + + let err: ContractError = app + .execute_contract( + Addr::unchecked(OWNER), + cw20_hooks_addr.clone(), + &ExecuteMsg::RemoveHook { + addr: ADDR2.to_string(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized {}); +} + +#[test] +pub fn test_ownership_transfer() { + let mut app = App::default(); + let cw20_hooks_addr = setup_contract(&mut app); + + // Ensure owner is set. + let ownership: Ownership = app + .wrap() + .query_wasm_smart(cw20_hooks_addr.clone(), &QueryMsg::Ownership {}) + .unwrap(); + assert_eq!( + ownership, + Ownership { + owner: Some(Addr::unchecked(OWNER)), + pending_owner: None, + pending_expiry: None, + } + ); + + // Fail to transfer owner if not owner. + let err: ContractError = app + .execute_contract( + Addr::unchecked(NOONE), + cw20_hooks_addr.clone(), + &ExecuteMsg::UpdateOwnership(cw_ownable::Action::TransferOwnership { + new_owner: NOONE.to_string(), + expiry: None, + }), + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::Ownable(cw_ownable::OwnershipError::NotOwner) + ); + + // Initiate transfer if owner. + app.execute_contract( + Addr::unchecked(OWNER), + cw20_hooks_addr.clone(), + &ExecuteMsg::UpdateOwnership(cw_ownable::Action::TransferOwnership { + new_owner: ADDR3.to_string(), + expiry: None, + }), + &[], + ) + .unwrap(); + + // Accept transfer from new owner. + app.execute_contract( + Addr::unchecked(ADDR3), + cw20_hooks_addr.clone(), + &ExecuteMsg::UpdateOwnership(cw_ownable::Action::AcceptOwnership {}), + &[], + ) + .unwrap(); + + // Ensure owner was transferred. + let ownership: Ownership = app + .wrap() + .query_wasm_smart(cw20_hooks_addr.clone(), &QueryMsg::Ownership {}) + .unwrap(); + assert_eq!( + ownership, + Ownership { + owner: Some(Addr::unchecked(ADDR3)), + pending_owner: None, + pending_expiry: None, + } + ); +} + +#[test] +fn owner_can_update_minter_but_not_cap() { + let mut app = App::default(); + let cw20_hooks_addr = setup_contract(&mut app); + + // Ensure minter set. + let minter: Option = app + .wrap() + .query_wasm_smart(cw20_hooks_addr.clone(), &QueryMsg::Minter {}) + .unwrap(); + assert_eq!( + minter, + Some(MinterResponse { + minter: OWNER.to_string(), + cap: Some(Uint128::new(500_000_000)) + }) + ); + + // Change minter. + app.execute_contract( + Addr::unchecked(OWNER), + cw20_hooks_addr.clone(), + &ExecuteMsg::UpdateMinter { + new_minter: Some(ADDR2.to_string()), + }, + &[], + ) + .unwrap(); + + // Ensure minter changed with same cap as before. + let minter: Option = app + .wrap() + .query_wasm_smart(cw20_hooks_addr.clone(), &QueryMsg::Minter {}) + .unwrap(); + assert_eq!( + minter, + Some(MinterResponse { + minter: ADDR2.to_string(), + cap: Some(Uint128::new(500_000_000)) + }) + ); + + // Remove minter. + app.execute_contract( + Addr::unchecked(OWNER), + cw20_hooks_addr.clone(), + &ExecuteMsg::UpdateMinter { new_minter: None }, + &[], + ) + .unwrap(); + + // Ensure minter cleared. + let minter: Option = app + .wrap() + .query_wasm_smart(cw20_hooks_addr.clone(), &QueryMsg::Minter {}) + .unwrap(); + assert_eq!(minter, None); + + // Set minter again. + app.execute_contract( + Addr::unchecked(OWNER), + cw20_hooks_addr.clone(), + &ExecuteMsg::UpdateMinter { + new_minter: Some(ADDR3.to_string()), + }, + &[], + ) + .unwrap(); + + // Ensure minter set again with same cap as before. + let minter: Option = app + .wrap() + .query_wasm_smart(cw20_hooks_addr.clone(), &QueryMsg::Minter {}) + .unwrap(); + assert_eq!( + minter, + Some(MinterResponse { + minter: ADDR3.to_string(), + cap: Some(Uint128::new(500_000_000)) + }) + ); +} + +#[test] +fn owner_can_update_marketing_info() { + let mut app = App::default(); + let cw20_hooks_addr = setup_contract(&mut app); + + // Set marketing info. + app.execute_contract( + Addr::unchecked(OWNER), + cw20_hooks_addr.clone(), + &ExecuteMsg::UpdateMarketing { + project: Some("project".to_string()), + description: Some("description".to_string()), + marketing: Some(ADDR2.to_string()), + }, + &[], + ) + .unwrap(); + + // Ensure marketer can update marketing info. + app.execute_contract( + Addr::unchecked(ADDR2), + cw20_hooks_addr.clone(), + &ExecuteMsg::UpdateMarketing { + project: Some("new_project".to_string()), + description: None, + marketing: None, + }, + &[], + ) + .unwrap(); + + // Ensure owner can update marketing info and clear marketer. + app.execute_contract( + Addr::unchecked(OWNER), + cw20_hooks_addr.clone(), + &ExecuteMsg::UpdateMarketing { + project: Some("new_new_project".to_string()), + description: Some("new_new_description".to_string()), + marketing: Some("".to_string()), + }, + &[], + ) + .unwrap(); + + // Ensure marketer can no longer update marketing info. + let err: ContractError = app + .execute_contract( + Addr::unchecked(ADDR2), + cw20_hooks_addr.clone(), + &ExecuteMsg::UpdateMarketing { + project: Some("project".to_string()), + description: None, + marketing: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized {}); + + // Ensure owner can update marketing info even if marketer was unset. + app.execute_contract( + Addr::unchecked(OWNER), + cw20_hooks_addr.clone(), + &ExecuteMsg::UpdateMarketing { + project: Some("old_project".to_string()), + description: None, + marketing: Some(ADDR3.to_string()), + }, + &[], + ) + .unwrap(); + + // Remove owner. + app.execute_contract( + Addr::unchecked(OWNER), + cw20_hooks_addr.clone(), + &ExecuteMsg::UpdateOwnership(cw_ownable::Action::RenounceOwnership {}), + &[], + ) + .unwrap(); + + // Owner can no longer update marketing info. + let err: ContractError = app + .execute_contract( + Addr::unchecked(OWNER), + cw20_hooks_addr.clone(), + &ExecuteMsg::UpdateMarketing { + project: Some("another_project".to_string()), + description: None, + marketing: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized {}); + + // Ensure marketer can still update marketing info. + app.execute_contract( + Addr::unchecked(ADDR3), + cw20_hooks_addr.clone(), + &ExecuteMsg::UpdateMarketing { + project: Some("my_project".to_string()), + description: None, + marketing: None, + }, + &[], + ) + .unwrap(); +} diff --git a/contracts/external/cw20-hooks/src/tests_cw20_base.rs b/contracts/external/cw20-hooks/src/tests_cw20_base.rs new file mode 100644 index 000000000..49b4e10d8 --- /dev/null +++ b/contracts/external/cw20-hooks/src/tests_cw20_base.rs @@ -0,0 +1,1711 @@ +use cosmwasm_std::testing::{ + mock_dependencies, mock_dependencies_with_balance, mock_env, mock_info, +}; +use cosmwasm_std::{ + coins, from_json, Addr, Binary, CosmosMsg, Deps, DepsMut, StdError, SubMsg, Uint128, WasmMsg, +}; +use cw20::*; +use cw20_base::{ + contract::{query_balance, query_minter, query_token_info}, + msg::InstantiateMsg as Cw20InstantiateMsg, + ContractError as Cw20ContractError, +}; + +use crate::{contract::*, msg::*, ContractError}; + +// TESTS COPIED FROM CW20-BASE + +fn get_balance>(deps: Deps, address: T) -> Uint128 { + query_balance(deps, address.into()).unwrap().balance +} + +// this will set up the instantiation for other tests +fn do_instantiate_with_minter( + deps: DepsMut, + addr: &str, + amount: Uint128, + minter: &str, + cap: Option, +) -> TokenInfoResponse { + _do_instantiate( + deps, + addr, + amount, + Some(MinterResponse { + minter: minter.to_string(), + cap, + }), + ) +} + +// this will set up the instantiation for other tests +fn do_instantiate(deps: DepsMut, addr: &str, amount: Uint128) -> TokenInfoResponse { + _do_instantiate(deps, addr, amount, None) +} + +// this will set up the instantiation for other tests +fn _do_instantiate( + mut deps: DepsMut, + addr: &str, + amount: Uint128, + mint: Option, +) -> TokenInfoResponse { + let instantiate_msg = InstantiateMsg { + owner: Some("owner".to_string()), + name: "Auto Gen".to_string(), + symbol: "AUTO".to_string(), + decimals: 3, + initial_balances: vec![Cw20Coin { + address: addr.to_string(), + amount, + }], + mint: mint.clone(), + marketing: None, + }; + let info = mock_info("creator", &[]); + let env = mock_env(); + let res = instantiate(deps.branch(), env, info, instantiate_msg).unwrap(); + assert_eq!(0, res.messages.len()); + + let meta = query_token_info(deps.as_ref()).unwrap(); + assert_eq!( + meta, + TokenInfoResponse { + name: "Auto Gen".to_string(), + symbol: "AUTO".to_string(), + decimals: 3, + total_supply: amount, + } + ); + assert_eq!(get_balance(deps.as_ref(), addr), amount); + assert_eq!(query_minter(deps.as_ref()).unwrap(), mint,); + meta +} + +const PNG_HEADER: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]; + +mod instantiate { + use super::*; + + #[test] + fn basic() { + let mut deps = mock_dependencies(); + let addr = deps.api.addr_make("addr0000"); + let amount = Uint128::from(11223344u128); + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![Cw20Coin { + address: addr.to_string(), + amount, + }], + mint: None, + marketing: None, + }; + let info = mock_info("creator", &[]); + let env = mock_env(); + let res = instantiate(deps.as_mut(), env, info, instantiate_msg).unwrap(); + assert_eq!(0, res.messages.len()); + + assert_eq!( + query_token_info(deps.as_ref()).unwrap(), + TokenInfoResponse { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + total_supply: amount, + } + ); + assert_eq!(get_balance(deps.as_ref(), addr), Uint128::new(11223344)); + } + + #[test] + fn mintable() { + let mut deps = mock_dependencies(); + let addr = deps.api.addr_make("addr0000"); + let amount = Uint128::new(11223344); + let minter = deps.api.addr_make("asmodat").to_string(); + let limit = Uint128::new(511223344); + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![Cw20Coin { + address: addr.to_string(), + amount, + }], + mint: Some(MinterResponse { + minter: minter.clone(), + cap: Some(limit), + }), + marketing: None, + }; + let info = mock_info("creator", &[]); + let env = mock_env(); + let res = instantiate(deps.as_mut(), env, info, instantiate_msg).unwrap(); + assert_eq!(0, res.messages.len()); + + assert_eq!( + query_token_info(deps.as_ref()).unwrap(), + TokenInfoResponse { + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + total_supply: amount, + } + ); + assert_eq!(get_balance(deps.as_ref(), addr), Uint128::new(11223344)); + assert_eq!( + query_minter(deps.as_ref()).unwrap(), + Some(MinterResponse { + minter, + cap: Some(limit), + }), + ); + } + + #[test] + fn mintable_over_cap() { + let mut deps = mock_dependencies(); + let amount = Uint128::new(11223344); + let minter = deps.api.addr_make("asmodat"); + let addr = deps.api.addr_make("addr0000"); + let limit = Uint128::new(11223300); + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![Cw20Coin { + address: addr.to_string(), + amount, + }], + mint: Some(MinterResponse { + minter: minter.to_string(), + cap: Some(limit), + }), + marketing: None, + }; + let info = mock_info("creator", &[]); + let env = mock_env(); + let err = instantiate(deps.as_mut(), env, info, instantiate_msg).unwrap_err(); + assert_eq!( + err, + ContractError::Cw20(StdError::generic_err("Initial supply greater than cap").into()) + ); + } + + mod marketing { + use cw20_base::contract::{query_download_logo, query_marketing_info}; + + use super::*; + + #[test] + fn basic() { + let mut deps = mock_dependencies(); + + let marketing = deps.api.addr_make("marketing"); + + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(marketing.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info("creator", &[]); + let env = mock_env(); + let res = instantiate(deps.as_mut(), env, info, instantiate_msg).unwrap(); + assert_eq!(0, res.messages.len()); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(marketing), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn invalid_marketing() { + let mut deps = mock_dependencies(); + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some("m".to_owned()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info("creator", &[]); + let env = mock_env(); + instantiate(deps.as_mut(), env, info, instantiate_msg).unwrap_err(); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + } +} + +#[test] +fn can_mint_by_minter() { + let mut deps = mock_dependencies(); + + let genesis = deps.api.addr_make("genesis").to_string(); + let amount = Uint128::new(11223344); + let minter = deps.api.addr_make("asmodat").to_string(); + let limit = Uint128::new(511223344); + do_instantiate_with_minter(deps.as_mut(), &genesis, amount, &minter, Some(limit)); + + // minter can mint coins to some winner + let winner = deps.api.addr_make("winner").to_string(); + let prize = Uint128::new(222_222_222); + let msg = ExecuteMsg::Mint { + recipient: winner.clone(), + amount: prize, + }; + + let info = mock_info(minter.as_ref(), &[]); + let env = mock_env(); + let res = execute(deps.as_mut(), env, info, msg).unwrap(); + assert_eq!(0, res.messages.len()); + assert_eq!(get_balance(deps.as_ref(), genesis), amount); + assert_eq!(get_balance(deps.as_ref(), winner.clone()), prize); + + // Allows minting 0 + let msg = ExecuteMsg::Mint { + recipient: winner.clone(), + amount: Uint128::zero(), + }; + let info = mock_info(minter.as_ref(), &[]); + let env = mock_env(); + execute(deps.as_mut(), env, info, msg).unwrap(); + + // but if it exceeds cap (even over multiple rounds), it fails + // cap is enforced + let msg = ExecuteMsg::Mint { + recipient: winner, + amount: Uint128::new(333_222_222), + }; + let info = mock_info(minter.as_ref(), &[]); + let env = mock_env(); + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert_eq!( + err, + ContractError::Cw20(Cw20ContractError::CannotExceedCap {}) + ); +} + +#[test] +fn others_cannot_mint() { + let mut deps = mock_dependencies(); + + let genesis = deps.api.addr_make("genesis").to_string(); + let minter = deps.api.addr_make("minter").to_string(); + let winner = deps.api.addr_make("winner").to_string(); + + do_instantiate_with_minter(deps.as_mut(), &genesis, Uint128::new(1234), &minter, None); + + let msg = ExecuteMsg::Mint { + recipient: winner, + amount: Uint128::new(222), + }; + let info = mock_info("anyone else", &[]); + let env = mock_env(); + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert_eq!(err, ContractError::Cw20(Cw20ContractError::Unauthorized {})); +} + +// MODIFIED FROM cw20-base. Only the owner can update minter now. +#[test] +fn minter_cannot_update_minter() { + let mut deps = mock_dependencies(); + + let genesis = deps.api.addr_make("genesis").to_string(); + let minter = deps.api.addr_make("minter").to_string(); + + let cap = Some(Uint128::from(3000000u128)); + do_instantiate_with_minter(deps.as_mut(), &genesis, Uint128::new(1234), &minter, cap); + + let new_minter = deps.api.addr_make("new_minter").to_string(); + let msg = ExecuteMsg::UpdateMinter { + new_minter: Some(new_minter.clone()), + }; + + let info = mock_info(&minter, &[]); + let env = mock_env(); + let err = execute(deps.as_mut(), env.clone(), info, msg).unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); +} + +#[test] +fn others_cannot_update_minter() { + let mut deps = mock_dependencies(); + + let genesis = deps.api.addr_make("genesis").to_string(); + let minter = deps.api.addr_make("minter").to_string(); + let new_minter = deps.api.addr_make("new_minter").to_string(); + + do_instantiate_with_minter(deps.as_mut(), &genesis, Uint128::new(1234), &minter, None); + + let msg = ExecuteMsg::UpdateMinter { + new_minter: Some(new_minter), + }; + + let info = mock_info("not the minter", &[]); + let env = mock_env(); + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert_eq!(err, ContractError::Unauthorized {}); +} + +#[test] +fn unset_minter() { + let mut deps = mock_dependencies(); + + let genesis = deps.api.addr_make("genesis").to_string(); + let minter = deps.api.addr_make("minter").to_string(); + let winner = deps.api.addr_make("winner").to_string(); + + let cap = None; + do_instantiate_with_minter(deps.as_mut(), &genesis, Uint128::new(1234), &minter, cap); + + let msg = ExecuteMsg::UpdateMinter { new_minter: None }; + + let info = mock_info("owner", &[]); + let env = mock_env(); + let res = execute(deps.as_mut(), env.clone(), info, msg); + assert!(res.is_ok()); + let query_minter_msg = QueryMsg::Minter {}; + let res = query(deps.as_ref(), env, query_minter_msg); + let mint: Option = from_json(res.unwrap()).unwrap(); + + // Check that mint information was removed. + assert_eq!(mint, None); + + // Check that old minter can no longer mint. + let msg = ExecuteMsg::Mint { + recipient: winner, + amount: Uint128::new(222), + }; + let info = mock_info(&minter, &[]); + let env = mock_env(); + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert_eq!(err, ContractError::Cw20(Cw20ContractError::Unauthorized {})); +} + +#[test] +fn no_one_mints_if_minter_unset() { + let mut deps = mock_dependencies(); + + let genesis = deps.api.addr_make("genesis").to_string(); + let winner = deps.api.addr_make("winner").to_string(); + + do_instantiate(deps.as_mut(), &genesis, Uint128::new(1234)); + + let msg = ExecuteMsg::Mint { + recipient: winner, + amount: Uint128::new(222), + }; + let info = mock_info(&genesis, &[]); + let env = mock_env(); + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert_eq!(err, ContractError::Cw20(Cw20ContractError::Unauthorized {})); +} + +#[test] +fn instantiate_multiple_accounts() { + let mut deps = mock_dependencies(); + let amount1 = Uint128::from(11223344u128); + let addr1 = deps.api.addr_make("addr0001").to_string(); + let amount2 = Uint128::from(7890987u128); + let addr2 = deps.api.addr_make("addr0002").to_string(); + let info = mock_info("creator", &[]); + let env = mock_env(); + + // Fails with duplicate addresses + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Bash Shell".to_string(), + symbol: "BASH".to_string(), + decimals: 6, + initial_balances: vec![ + Cw20Coin { + address: addr1.clone(), + amount: amount1, + }, + Cw20Coin { + address: addr1.clone(), + amount: amount2, + }, + ], + mint: None, + marketing: None, + }; + let err = instantiate(deps.as_mut(), env.clone(), info.clone(), instantiate_msg).unwrap_err(); + assert_eq!( + err, + ContractError::Cw20(Cw20ContractError::DuplicateInitialBalanceAddresses {}) + ); + + // Works with unique addresses + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Bash Shell".to_string(), + symbol: "BASH".to_string(), + decimals: 6, + initial_balances: vec![ + Cw20Coin { + address: addr1.clone(), + amount: amount1, + }, + Cw20Coin { + address: addr2.clone(), + amount: amount2, + }, + ], + mint: None, + marketing: None, + }; + let res = instantiate(deps.as_mut(), env, info, instantiate_msg).unwrap(); + assert_eq!(0, res.messages.len()); + assert_eq!( + query_token_info(deps.as_ref()).unwrap(), + TokenInfoResponse { + name: "Bash Shell".to_string(), + symbol: "BASH".to_string(), + decimals: 6, + total_supply: amount1 + amount2, + } + ); + assert_eq!(get_balance(deps.as_ref(), addr1), amount1); + assert_eq!(get_balance(deps.as_ref(), addr2), amount2); +} + +#[test] +fn queries_work() { + let mut deps = mock_dependencies_with_balance(&coins(2, "token")); + + let addr1 = deps.api.addr_make("addr0001").to_string(); + let addr2 = deps.api.addr_make("addr0002").to_string(); + + let amount1 = Uint128::from(12340000u128); + + let expected = do_instantiate(deps.as_mut(), &addr1, amount1); + + // check meta query + let loaded = query_token_info(deps.as_ref()).unwrap(); + assert_eq!(expected, loaded); + + let _info = mock_info("test", &[]); + let env = mock_env(); + // check balance query (full) + let data = query( + deps.as_ref(), + env.clone(), + QueryMsg::Balance { address: addr1 }, + ) + .unwrap(); + let loaded: BalanceResponse = from_json(data).unwrap(); + assert_eq!(loaded.balance, amount1); + + // check balance query (empty) + let data = query(deps.as_ref(), env, QueryMsg::Balance { address: addr2 }).unwrap(); + let loaded: BalanceResponse = from_json(data).unwrap(); + assert_eq!(loaded.balance, Uint128::zero()); +} + +#[test] +fn transfer() { + let mut deps = mock_dependencies_with_balance(&coins(2, "token")); + let addr1 = deps.api.addr_make("addr0001").to_string(); + let addr2 = deps.api.addr_make("addr0002").to_string(); + let amount1 = Uint128::from(12340000u128); + let transfer = Uint128::from(76543u128); + let too_much = Uint128::from(12340321u128); + + do_instantiate(deps.as_mut(), &addr1, amount1); + + // Allows transferring 0 + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Transfer { + recipient: addr2.clone(), + amount: Uint128::zero(), + }; + execute(deps.as_mut(), env, info, msg).unwrap(); + + // cannot send more than we have + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Transfer { + recipient: addr2.clone(), + amount: too_much, + }; + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert!(matches!(err, ContractError::Std(StdError::Overflow { .. }))); + + // cannot send from empty account + let info = mock_info(addr2.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Transfer { + recipient: addr1.clone(), + amount: transfer, + }; + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert!(matches!(err, ContractError::Std(StdError::Overflow { .. }))); + + // valid transfer + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Transfer { + recipient: addr2.clone(), + amount: transfer, + }; + let res = execute(deps.as_mut(), env, info, msg).unwrap(); + assert_eq!(res.messages.len(), 0); + + let remainder = amount1.checked_sub(transfer).unwrap(); + assert_eq!(get_balance(deps.as_ref(), addr1), remainder); + assert_eq!(get_balance(deps.as_ref(), addr2), transfer); + assert_eq!( + query_token_info(deps.as_ref()).unwrap().total_supply, + amount1 + ); +} + +#[test] +fn burn() { + let mut deps = mock_dependencies_with_balance(&coins(2, "token")); + let addr1 = deps.api.addr_make("addr0001").to_string(); + let amount1 = Uint128::from(12340000u128); + let burn = Uint128::from(76543u128); + let too_much = Uint128::from(12340321u128); + + do_instantiate(deps.as_mut(), &addr1, amount1); + + // Allows burning 0 + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Burn { + amount: Uint128::zero(), + }; + execute(deps.as_mut(), env, info, msg).unwrap(); + assert_eq!( + query_token_info(deps.as_ref()).unwrap().total_supply, + amount1 + ); + + // cannot burn more than we have + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Burn { amount: too_much }; + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert!(matches!( + err, + ContractError::Cw20(Cw20ContractError::Std(StdError::Overflow { .. })) + )); + assert_eq!( + query_token_info(deps.as_ref()).unwrap().total_supply, + amount1 + ); + + // valid burn reduces total supply + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Burn { amount: burn }; + let res = execute(deps.as_mut(), env, info, msg).unwrap(); + assert_eq!(res.messages.len(), 0); + + let remainder = amount1.checked_sub(burn).unwrap(); + assert_eq!(get_balance(deps.as_ref(), addr1), remainder); + assert_eq!( + query_token_info(deps.as_ref()).unwrap().total_supply, + remainder + ); +} + +#[test] +fn send() { + let mut deps = mock_dependencies_with_balance(&coins(2, "token")); + let addr1 = deps.api.addr_make("addr0001").to_string(); + let contract = deps.api.addr_make("contract0001").to_string(); + let amount1 = Uint128::from(12340000u128); + let transfer = Uint128::from(76543u128); + let too_much = Uint128::from(12340321u128); + let send_msg = Binary::from(r#"{"some":123}"#.as_bytes()); + + do_instantiate(deps.as_mut(), &addr1, amount1); + + // Allows sending 0 + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Send { + contract: contract.clone(), + amount: Uint128::zero(), + msg: send_msg.clone(), + }; + execute(deps.as_mut(), env, info, msg).unwrap(); + + // cannot send more than we have + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Send { + contract: contract.clone(), + amount: too_much, + msg: send_msg.clone(), + }; + let err = execute(deps.as_mut(), env, info, msg).unwrap_err(); + assert!(matches!(err, ContractError::Std(StdError::Overflow { .. }))); + + // valid transfer + let info = mock_info(addr1.as_ref(), &[]); + let env = mock_env(); + let msg = ExecuteMsg::Send { + contract: contract.clone(), + amount: transfer, + msg: send_msg.clone(), + }; + let res = execute(deps.as_mut(), env, info, msg).unwrap(); + assert_eq!(res.messages.len(), 1); + + // ensure proper send message sent + // this is the message we want delivered to the other side + let binary_msg = Cw20ReceiveMsg { + sender: addr1.clone(), + amount: transfer, + msg: send_msg, + } + .into_binary() + .unwrap(); + // and this is how it must be wrapped for the vm to process it + assert_eq!( + res.messages[0], + SubMsg::new(CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: contract.clone(), + msg: binary_msg, + funds: vec![], + })) + ); + + // ensure balance is properly transferred + let remainder = amount1.checked_sub(transfer).unwrap(); + assert_eq!(get_balance(deps.as_ref(), addr1), remainder); + assert_eq!(get_balance(deps.as_ref(), contract), transfer); + assert_eq!( + query_token_info(deps.as_ref()).unwrap().total_supply, + amount1 + ); +} + +mod migration { + use super::*; + + use cosmwasm_std::{to_json_binary, Empty}; + use cw20::{AllAllowancesResponse, AllSpenderAllowancesResponse, SpenderAllowanceInfo}; + use cw_multi_test::{App, Contract, ContractWrapper, Executor}; + use cw_utils::Expiration; + + fn cw20_base_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_base::contract::execute, + cw20_base::contract::instantiate, + cw20_base::contract::query, + ); + Box::new(contract) + } + + fn cw20_hooks_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ) + .with_migrate(crate::contract::migrate); + Box::new(contract) + } + + #[test] + fn test_migrate() { + let mut app = App::default(); + + let sender = app.api().addr_make("sender").to_string(); + let spender = app.api().addr_make("spender").to_string(); + + let cw20_base_id = app.store_code(cw20_base_contract()); + let cw20_hooks_id = app.store_code(cw20_hooks_contract()); + let cw20_base_addr = app + .instantiate_contract( + cw20_base_id, + Addr::unchecked("sender"), + &Cw20InstantiateMsg { + name: "Token".to_string(), + symbol: "TOKEN".to_string(), + decimals: 6, + initial_balances: vec![Cw20Coin { + address: sender.clone(), + amount: Uint128::new(100), + }], + mint: None, + marketing: None, + }, + &[], + "TOKEN", + Some(sender.clone()), + ) + .unwrap(); + + // no allowance to start + let allowance: AllAllowancesResponse = app + .wrap() + .query_wasm_smart( + cw20_base_addr.to_string(), + &QueryMsg::AllAllowances { + owner: sender.clone(), + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!(allowance, AllAllowancesResponse::default()); + + // Set allowance + let allow1 = Uint128::new(7777); + let expires = Expiration::AtHeight(123_456); + let msg = CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: cw20_base_addr.to_string(), + msg: to_json_binary(&ExecuteMsg::IncreaseAllowance { + spender: spender.clone(), + amount: allow1, + expires: Some(expires), + }) + .unwrap(), + funds: vec![], + }); + app.execute(Addr::unchecked(&sender), msg).unwrap(); + + // Now migrate + app.execute( + Addr::unchecked(&sender), + CosmosMsg::Wasm(WasmMsg::Migrate { + contract_addr: cw20_base_addr.to_string(), + new_code_id: cw20_hooks_id, + msg: to_json_binary(&MigrateMsg::FromBase { + owner: "owner".to_string(), + }) + .unwrap(), + }), + ) + .unwrap(); + + // Smoke check that the contract still works. + let balance: cw20::BalanceResponse = app + .wrap() + .query_wasm_smart( + cw20_base_addr.clone(), + &QueryMsg::Balance { + address: sender.clone(), + }, + ) + .unwrap(); + + assert_eq!(balance.balance, Uint128::new(100)); + + // Confirm that the allowance per spender is there + let allowance: AllSpenderAllowancesResponse = app + .wrap() + .query_wasm_smart( + cw20_base_addr, + &QueryMsg::AllSpenderAllowances { + spender, + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!( + allowance.allowances, + &[SpenderAllowanceInfo { + owner: sender, + allowance: allow1, + expires + }] + ); + } +} + +mod marketing { + use cw20_base::contract::{query_download_logo, query_marketing_info}; + + use super::*; + + #[test] + fn update_unauthorised() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + let marketing = deps.api.addr_make("marketing"); + + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(marketing.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateMarketing { + project: Some("New project".to_owned()), + description: Some("Better description".to_owned()), + marketing: Some(creator.to_string()), + }, + ) + .unwrap_err(); + + assert_eq!(err, ContractError::Unauthorized {}); + + // Ensure marketing didn't change + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(marketing), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_project() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateMarketing { + project: Some("New project".to_owned()), + description: None, + marketing: None, + }, + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("New project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn clear_project() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateMarketing { + project: Some("".to_owned()), + description: None, + marketing: None, + }, + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: None, + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_description() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateMarketing { + project: None, + description: Some("Better description".to_owned()), + marketing: None, + }, + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Better description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn clear_description() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateMarketing { + project: None, + description: Some("".to_owned()), + marketing: None, + }, + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: None, + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_marketing() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + let marketing = deps.api.addr_make("marketing"); + + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateMarketing { + project: None, + description: None, + marketing: Some(marketing.to_string()), + }, + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(marketing), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_marketing_invalid() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateMarketing { + project: None, + description: None, + marketing: Some("m".to_owned()), + }, + ) + .unwrap_err(); + + assert!( + matches!(err, ContractError::Std(_)), + "Expected Std error, received: {err}", + ); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn clear_marketing() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UpdateMarketing { + project: None, + description: None, + marketing: Some("".to_owned()), + }, + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: None, + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_logo_url() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UploadLogo(Logo::Url("new_url".to_owned())), + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("new_url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_logo_png() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UploadLogo(Logo::Embedded(EmbeddedLogo::Png(PNG_HEADER.into()))), + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Embedded), + } + ); + + assert_eq!( + query_download_logo(deps.as_ref()).unwrap(), + DownloadLogoResponse { + mime_type: "image/png".to_owned(), + data: PNG_HEADER.into(), + } + ); + } + + #[test] + fn update_logo_svg() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let img = "".as_bytes(); + let res = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UploadLogo(Logo::Embedded(EmbeddedLogo::Svg(img.into()))), + ) + .unwrap(); + + assert_eq!(res.messages, vec![]); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Embedded), + } + ); + + assert_eq!( + query_download_logo(deps.as_ref()).unwrap(), + DownloadLogoResponse { + mime_type: "image/svg+xml".to_owned(), + data: img.into(), + } + ); + } + + #[test] + fn update_logo_png_oversized() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let img = [&PNG_HEADER[..], &[1; 6000][..]].concat(); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UploadLogo(Logo::Embedded(EmbeddedLogo::Png(img.into()))), + ) + .unwrap_err(); + + assert_eq!(err, ContractError::Cw20(Cw20ContractError::LogoTooBig {})); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_logo_svg_oversized() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let img = [ + "", + std::str::from_utf8(&[b'x'; 6000]).unwrap(), + "", + ] + .concat() + .into_bytes(); + + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UploadLogo(Logo::Embedded(EmbeddedLogo::Svg(img.into()))), + ) + .unwrap_err(); + + assert_eq!(err, ContractError::Cw20(Cw20ContractError::LogoTooBig {})); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_logo_png_invalid() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info(creator.as_str(), &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let img = &[1]; + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UploadLogo(Logo::Embedded(EmbeddedLogo::Png(img.into()))), + ) + .unwrap_err(); + + assert_eq!( + err, + ContractError::Cw20(Cw20ContractError::InvalidPngHeader {}) + ); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } + + #[test] + fn update_logo_svg_invalid() { + let mut deps = mock_dependencies(); + + let creator = deps.api.addr_make("creator"); + + let instantiate_msg = InstantiateMsg { + owner: None, + name: "Cash Token".to_string(), + symbol: "CASH".to_string(), + decimals: 9, + initial_balances: vec![], + mint: None, + marketing: Some(InstantiateMarketingInfo { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator.to_string()), + logo: Some(Logo::Url("url".to_owned())), + }), + }; + + let info = mock_info("creator", &[]); + + instantiate(deps.as_mut(), mock_env(), info.clone(), instantiate_msg).unwrap(); + + let img = &[1]; + + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::UploadLogo(Logo::Embedded(EmbeddedLogo::Svg(img.into()))), + ) + .unwrap_err(); + + assert_eq!( + err, + ContractError::Cw20(Cw20ContractError::InvalidXmlPreamble {}) + ); + + assert_eq!( + query_marketing_info(deps.as_ref()).unwrap(), + MarketingInfoResponse { + project: Some("Project".to_owned()), + description: Some("Description".to_owned()), + marketing: Some(creator), + logo: Some(LogoInfo::Url("url".to_owned())), + } + ); + + let err = query_download_logo(deps.as_ref()).unwrap_err(); + assert!( + matches!(err, StdError::NotFound { .. }), + "Expected StdError::NotFound, received {err}", + ); + } +} diff --git a/contracts/external/dao-cw20-transfer-rules/.cargo/config b/contracts/external/dao-cw20-transfer-rules/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/external/dao-cw20-transfer-rules/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/external/dao-cw20-transfer-rules/Cargo.toml b/contracts/external/dao-cw20-transfer-rules/Cargo.toml new file mode 100644 index 000000000..728e06025 --- /dev/null +++ b/contracts/external/dao-cw20-transfer-rules/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "dao-cw20-transfer-rules" +authors = ["Jake Hartnell"] +description = "A CosmWasm contract that enforces transfer rules." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-ownable = { workspace = true } +cw-paginate-storage = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +cw20 = { workspace = true } +cw20-hooks = { workspace = true } +dao-interface = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cosmwasm-schema = { workspace = true } +cw-multi-test = { workspace = true } +cw20 = { workspace = true } +cw20-stake = { workspace = true, features = ["library"] } +dao-dao-core = { workspace = true, features = ["library"] } +dao-pre-propose-single = { workspace = true, features = ["library"] } +dao-proposal-single = { workspace = true, features = ["library"] } +dao-voting-cw20-staked = { workspace = true, features = ["library"] } +dao-voting = { workspace = true } +dao-testing = { workspace = true } diff --git a/contracts/external/dao-cw20-transfer-rules/README.md b/contracts/external/dao-cw20-transfer-rules/README.md new file mode 100644 index 000000000..2e90ac3c1 --- /dev/null +++ b/contracts/external/dao-cw20-transfer-rules/README.md @@ -0,0 +1,9 @@ +# dao-cw20-transfer-rules + +[![dao-cw20-transfer-rules on crates.io](https://img.shields.io/crates/v/dao-cw20-transfer-rules.svg?logo=rust)](https://crates.io/crates/dao-cw20-transfer-rules) +[![docs.rs](https://img.shields.io/docsrs/dao-cw20-transfer-rules?logo=docsdotrs)](https://docs.rs/dao-cw20-transfer-rules/latest/cw_admin_factory/) + +Enforces granular transfer rules on cw20 token transfer that can take into +account DAO membership. Addresses can optionally be allowed to send tokens, +receive tokens, or both, and a default can be set for DAO members with no +allowance specified. By default, no one can transfer. diff --git a/contracts/external/dao-cw20-transfer-rules/examples/schema.rs b/contracts/external/dao-cw20-transfer-rules/examples/schema.rs new file mode 100644 index 000000000..f50850c77 --- /dev/null +++ b/contracts/external/dao-cw20-transfer-rules/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use dao_cw20_transfer_rules::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/external/dao-cw20-transfer-rules/schema/dao-cw20-transfer-rules.json b/contracts/external/dao-cw20-transfer-rules/schema/dao-cw20-transfer-rules.json new file mode 100644 index 000000000..1ebaa5b6e --- /dev/null +++ b/contracts/external/dao-cw20-transfer-rules/schema/dao-cw20-transfer-rules.json @@ -0,0 +1,1020 @@ +{ + "contract_name": "dao-cw20-transfer-rules", + "contract_version": "2.4.2", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "dao" + ], + "properties": { + "allowances": { + "description": "An initial list of allowances allowances.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/AllowanceUpdate" + } + }, + "dao": { + "description": "The DAO whose members may be able to send and/or receive tokens.", + "type": "string" + }, + "member_allowance": { + "description": "The allowance assigned to DAO members with no explicit allowance set. If None, members with no allowance set cannot send nor receive tokens.", + "anyOf": [ + { + "$ref": "#/definitions/Allowance" + }, + { + "type": "null" + } + ] + }, + "owner": { + "description": "The address that can update the config and allowances.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false, + "definitions": { + "Allowance": { + "oneOf": [ + { + "description": "The address cannot send nor receive tokens.", + "type": "string", + "enum": [ + "none" + ] + }, + { + "description": "The address can send tokens to allowed recipients.", + "type": "string", + "enum": [ + "send" + ] + }, + { + "description": "The address can send tokens to anyone, regardless of allowance.", + "type": "string", + "enum": [ + "send_anywhere" + ] + }, + { + "description": "The address can receive tokens from allowed senders.", + "type": "string", + "enum": [ + "receive" + ] + }, + { + "description": "The address can receive tokens from anyone, regardless of allowance.", + "type": "string", + "enum": [ + "receive_anywhere" + ] + }, + { + "description": "The address can send/receive tokens to/from allowed recipients/senders.", + "type": "string", + "enum": [ + "send_and_receive" + ] + }, + { + "description": "The address can send/receive tokens to/from anyone, regardless of allowance.", + "type": "string", + "enum": [ + "send_and_receive_anywhere" + ] + } + ] + }, + "AllowanceUpdate": { + "type": "object", + "required": [ + "address", + "allowance" + ], + "properties": { + "address": { + "description": "The address to set the allowance for.", + "type": "string" + }, + "allowance": { + "description": "The allowance to set.", + "allOf": [ + { + "$ref": "#/definitions/Allowance" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "cw20_hook" + ], + "properties": { + "cw20_hook": { + "$ref": "#/definitions/Cw20HookMsg" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "update_allowances" + ], + "properties": { + "update_allowances": { + "type": "object", + "required": [ + "remove", + "set" + ], + "properties": { + "remove": { + "description": "Addresses to remove allowances from.", + "type": "array", + "items": { + "type": "string" + } + }, + "set": { + "description": "Addresses to add/update allowances for.", + "type": "array", + "items": { + "$ref": "#/definitions/AllowanceUpdate" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "properties": { + "dao": { + "description": "The DAO whose members may be able to send or receive tokens.", + "type": [ + "string", + "null" + ] + }, + "member_allowance": { + "description": "The allowance assigned to DAO members with no explicit allowance set. If None, members with no allowance set cannot send nor receive tokens.", + "anyOf": [ + { + "$ref": "#/definitions/Allowance" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Update the contract's ownership. The `action` to be provided can be either to propose transferring ownership to an account, accept a pending ownership transfer, or renounce the ownership permanently.", + "type": "object", + "required": [ + "update_ownership" + ], + "properties": { + "update_ownership": { + "$ref": "#/definitions/Action" + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Action": { + "description": "Actions that can be taken to alter the contract's ownership", + "oneOf": [ + { + "description": "Propose to transfer the contract's ownership to another account, optionally with an expiry time.\n\nCan only be called by the contract's current owner.\n\nAny existing pending ownership transfer is overwritten.", + "type": "object", + "required": [ + "transfer_ownership" + ], + "properties": { + "transfer_ownership": { + "type": "object", + "required": [ + "new_owner" + ], + "properties": { + "expiry": { + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "new_owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Accept the pending ownership transfer.\n\nCan only be called by the pending owner.", + "type": "string", + "enum": [ + "accept_ownership" + ] + }, + { + "description": "Give up the contract's ownership and the possibility of appointing a new owner.\n\nCan only be invoked by the contract's current owner.\n\nAny existing pending ownership transfer is canceled.", + "type": "string", + "enum": [ + "renounce_ownership" + ] + } + ] + }, + "Allowance": { + "oneOf": [ + { + "description": "The address cannot send nor receive tokens.", + "type": "string", + "enum": [ + "none" + ] + }, + { + "description": "The address can send tokens to allowed recipients.", + "type": "string", + "enum": [ + "send" + ] + }, + { + "description": "The address can send tokens to anyone, regardless of allowance.", + "type": "string", + "enum": [ + "send_anywhere" + ] + }, + { + "description": "The address can receive tokens from allowed senders.", + "type": "string", + "enum": [ + "receive" + ] + }, + { + "description": "The address can receive tokens from anyone, regardless of allowance.", + "type": "string", + "enum": [ + "receive_anywhere" + ] + }, + { + "description": "The address can send/receive tokens to/from allowed recipients/senders.", + "type": "string", + "enum": [ + "send_and_receive" + ] + }, + { + "description": "The address can send/receive tokens to/from anyone, regardless of allowance.", + "type": "string", + "enum": [ + "send_and_receive_anywhere" + ] + } + ] + }, + "AllowanceUpdate": { + "type": "object", + "required": [ + "address", + "allowance" + ], + "properties": { + "address": { + "description": "The address to set the allowance for.", + "type": "string" + }, + "allowance": { + "description": "The allowance to set.", + "allOf": [ + { + "$ref": "#/definitions/Allowance" + } + ] + } + }, + "additionalProperties": false + }, + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec. See also .", + "type": "string" + }, + "Cw20HookMsg": { + "oneOf": [ + { + "type": "object", + "required": [ + "transfer" + ], + "properties": { + "transfer": { + "type": "object", + "required": [ + "amount", + "recipient", + "sender" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "recipient": { + "type": "string" + }, + "sender": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "send" + ], + "properties": { + "send": { + "type": "object", + "required": [ + "amount", + "contract", + "msg", + "sender" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "contract": { + "type": "string" + }, + "msg": { + "$ref": "#/definitions/Binary" + }, + "sender": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Returns the paginated list of allowances.", + "type": "object", + "required": [ + "list_allowances" + ], + "properties": { + "list_allowances": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "description": "The address to start after.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the config.", + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns allowance for an address.", + "type": "object", + "required": [ + "allowance" + ], + "properties": { + "allowance": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns contract info.", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns info about the contract ownership.", + "type": "object", + "required": [ + "ownership" + ], + "properties": { + "ownership": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "additionalProperties": false + }, + "sudo": null, + "responses": { + "allowance": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllowanceResponse", + "type": "object", + "required": [ + "allowance", + "is_member_allowance" + ], + "properties": { + "allowance": { + "description": "The allowance.", + "allOf": [ + { + "$ref": "#/definitions/Allowance" + } + ] + }, + "is_member_allowance": { + "description": "Whether or not the allowance came from the member allowance fallback in the config.", + "type": "boolean" + } + }, + "additionalProperties": false, + "definitions": { + "Allowance": { + "oneOf": [ + { + "description": "The address cannot send nor receive tokens.", + "type": "string", + "enum": [ + "none" + ] + }, + { + "description": "The address can send tokens to allowed recipients.", + "type": "string", + "enum": [ + "send" + ] + }, + { + "description": "The address can send tokens to anyone, regardless of allowance.", + "type": "string", + "enum": [ + "send_anywhere" + ] + }, + { + "description": "The address can receive tokens from allowed senders.", + "type": "string", + "enum": [ + "receive" + ] + }, + { + "description": "The address can receive tokens from anyone, regardless of allowance.", + "type": "string", + "enum": [ + "receive_anywhere" + ] + }, + { + "description": "The address can send/receive tokens to/from allowed recipients/senders.", + "type": "string", + "enum": [ + "send_and_receive" + ] + }, + { + "description": "The address can send/receive tokens to/from anyone, regardless of allowance.", + "type": "string", + "enum": [ + "send_and_receive_anywhere" + ] + } + ] + } + } + }, + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ConfigResponse", + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "description": "The config.", + "allOf": [ + { + "$ref": "#/definitions/Config" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Allowance": { + "oneOf": [ + { + "description": "The address cannot send nor receive tokens.", + "type": "string", + "enum": [ + "none" + ] + }, + { + "description": "The address can send tokens to allowed recipients.", + "type": "string", + "enum": [ + "send" + ] + }, + { + "description": "The address can send tokens to anyone, regardless of allowance.", + "type": "string", + "enum": [ + "send_anywhere" + ] + }, + { + "description": "The address can receive tokens from allowed senders.", + "type": "string", + "enum": [ + "receive" + ] + }, + { + "description": "The address can receive tokens from anyone, regardless of allowance.", + "type": "string", + "enum": [ + "receive_anywhere" + ] + }, + { + "description": "The address can send/receive tokens to/from allowed recipients/senders.", + "type": "string", + "enum": [ + "send_and_receive" + ] + }, + { + "description": "The address can send/receive tokens to/from anyone, regardless of allowance.", + "type": "string", + "enum": [ + "send_and_receive_anywhere" + ] + } + ] + }, + "Config": { + "type": "object", + "required": [ + "dao", + "member_allowance" + ], + "properties": { + "dao": { + "description": "The DAO whose members may be able to send or receive tokens.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "member_allowance": { + "description": "The allowance assigned to DAO members with no explicit allowance set. If None, members with no allowance set cannot send nor receive tokens.", + "allOf": [ + { + "$ref": "#/definitions/Allowance" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "info": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InfoResponse", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "$ref": "#/definitions/ContractVersion" + } + }, + "additionalProperties": false, + "definitions": { + "ContractVersion": { + "type": "object", + "required": [ + "contract", + "version" + ], + "properties": { + "contract": { + "description": "contract is the crate name of the implementing contract, eg. `crate:cw20-base` we will use other prefixes for other languages, and their standard global namespacing", + "type": "string" + }, + "version": { + "description": "version is any string that this implementation knows. It may be simple counter \"1\", \"2\". or semantic version on release tags \"v0.7.0\", or some custom feature flag list. the only code that needs to understand the version parsing is code that knows how to migrate from the given contract (and is tied to it's implementation somehow)", + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "list_allowances": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ListAllowancesResponse", + "type": "object", + "required": [ + "allowances" + ], + "properties": { + "allowances": { + "description": "The allowances.", + "type": "array", + "items": { + "$ref": "#/definitions/AllowanceEntry" + } + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Allowance": { + "oneOf": [ + { + "description": "The address cannot send nor receive tokens.", + "type": "string", + "enum": [ + "none" + ] + }, + { + "description": "The address can send tokens to allowed recipients.", + "type": "string", + "enum": [ + "send" + ] + }, + { + "description": "The address can send tokens to anyone, regardless of allowance.", + "type": "string", + "enum": [ + "send_anywhere" + ] + }, + { + "description": "The address can receive tokens from allowed senders.", + "type": "string", + "enum": [ + "receive" + ] + }, + { + "description": "The address can receive tokens from anyone, regardless of allowance.", + "type": "string", + "enum": [ + "receive_anywhere" + ] + }, + { + "description": "The address can send/receive tokens to/from allowed recipients/senders.", + "type": "string", + "enum": [ + "send_and_receive" + ] + }, + { + "description": "The address can send/receive tokens to/from anyone, regardless of allowance.", + "type": "string", + "enum": [ + "send_and_receive_anywhere" + ] + } + ] + }, + "AllowanceEntry": { + "type": "object", + "required": [ + "address", + "allowance" + ], + "properties": { + "address": { + "description": "The address.", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "allowance": { + "description": "The allowance.", + "allOf": [ + { + "$ref": "#/definitions/Allowance" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "ownership": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Ownership_for_Addr", + "description": "The contract's ownership info", + "type": "object", + "properties": { + "owner": { + "description": "The contract's current owner. `None` if the ownership has been renounced.", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, + "pending_expiry": { + "description": "The deadline for the pending owner to accept the ownership. `None` if there isn't a pending ownership transfer, or if a transfer exists and it doesn't have a deadline.", + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "pending_owner": { + "description": "The account who has been proposed to take over the ownership. `None` if there isn't a pending ownership transfer.", + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } + } + } +} diff --git a/contracts/external/dao-cw20-transfer-rules/src/contract.rs b/contracts/external/dao-cw20-transfer-rules/src/contract.rs new file mode 100644 index 000000000..115b10ff6 --- /dev/null +++ b/contracts/external/dao-cw20-transfer-rules/src/contract.rs @@ -0,0 +1,301 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Order, Response, StdResult, +}; +use cw2::set_contract_version; +use cw20_hooks::hooks::Cw20HookMsg; +use cw_storage_plus::Bound; +use cw_utils::maybe_addr; +use dao_interface::msg::QueryMsg as DaoQueryMsg; +use dao_interface::voting::VotingPowerAtHeightResponse; + +use crate::error::ContractError; +use crate::msg::{ + AllowanceEntry, AllowanceResponse, AllowanceUpdate, ConfigResponse, ExecuteMsg, InstantiateMsg, + ListAllowancesResponse, MigrateMsg, QueryMsg, +}; +use crate::state::{Allowance, Config, ALLOWANCES, CONFIG}; + +pub(crate) const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +// Settings for query pagination +const MAX_LIMIT: u32 = 30; +const DEFAULT_LIMIT: u32 = 10; + +#[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)?; + + cw_ownable::initialize_owner(deps.storage, deps.api, msg.owner.as_deref())?; + + // Validate DAO address. + let dao = deps.api.addr_validate(&msg.dao)?; + // Query DAO for voting power of sender to verify it responds to the query. + let _: VotingPowerAtHeightResponse = deps + .querier + .query_wasm_smart( + dao.clone(), + &DaoQueryMsg::VotingPowerAtHeight { + address: info.sender.to_string(), + height: None, + }, + ) + .map_err(|error| ContractError::InvalidDao { error })?; + + // Save config. + CONFIG.save( + deps.storage, + &Config { + dao: dao.clone(), + member_allowance: msg.member_allowance.unwrap_or(Allowance::None), + }, + )?; + + // Initialize allowances if provided. + if let Some(allowances) = msg.allowances { + for entry in allowances { + if entry.allowance != Allowance::None { + ALLOWANCES.save( + deps.storage, + &deps.api.addr_validate(&entry.address)?, + &entry.allowance, + )?; + } + } + } + + Ok(Response::new() + .add_attribute("method", "instantiate") + .add_attribute("owner", msg.owner.unwrap_or("none".to_string())) + .add_attribute("dao", dao)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Cw20Hook(msg) => execute_check_transfer(deps, env, info, msg), + ExecuteMsg::UpdateAllowances { set, remove } => { + execute_update_allowances(deps, info, set, remove) + } + ExecuteMsg::UpdateConfig { + dao, + member_allowance, + } => execute_update_config(deps, info, dao, member_allowance), + ExecuteMsg::UpdateOwnership(action) => execute_update_owner(deps, info, env, action), + } +} + +pub fn execute_check_transfer( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: Cw20HookMsg, +) -> Result { + let (sender, recipient) = match msg { + Cw20HookMsg::Transfer { + sender, recipient, .. + } => ( + deps.api.addr_validate(&sender)?, + deps.api.addr_validate(&recipient)?, + ), + Cw20HookMsg::Send { + sender, contract, .. + } => ( + deps.api.addr_validate(&sender)?, + deps.api.addr_validate(&contract)?, + ), + }; + + // Check if sender can send. + let (sender_allowed, send_anywhere) = is_allowed(deps.as_ref(), sender.to_string(), true)?; + + // Check if recipient can receive. + let (recipient_allowed, receive_anywhere) = + is_allowed(deps.as_ref(), recipient.to_string(), false)?; + + // Unauthorized if sender cannot send AND recipient cannot receive from any + // sender. + if !sender_allowed && !receive_anywhere { + return Err(ContractError::UnauthorizedSender {}); + } + + // Unauthorized if recipient cannot receive AND sender cannot send to any + // recipient. + if !recipient_allowed && !send_anywhere { + return Err(ContractError::UnauthorizedRecipient {}); + } + + Ok(Response::default()) +} + +// Return whether or not the address can send/receive and if they can do so +// to/from anywhere. +pub fn is_allowed( + deps: Deps, + address: String, + // If true, check if allowed to send. If false, check if allowed to receive. + sending: bool, +) -> StdResult<(bool, bool)> { + let allowance = query_allowance(deps, address)?.allowance; + Ok(match allowance { + Allowance::None => (false, false), + Allowance::Send => (sending, false), + Allowance::SendAnywhere => (sending, true), + Allowance::Receive => (!sending, false), + Allowance::ReceiveAnywhere => (!sending, true), + Allowance::SendAndReceive => (true, false), + Allowance::SendAndReceiveAnywhere => (true, true), + }) +} + +pub fn execute_update_allowances( + deps: DepsMut, + info: MessageInfo, + set: Vec, + remove: Vec, +) -> Result { + // Check if sender is the owner. + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + // Update address allowances. + for entry in set { + ALLOWANCES.save( + deps.storage, + &deps.api.addr_validate(&entry.address)?, + &entry.allowance, + )?; + } + + // Remove address allowances. + for address in remove { + ALLOWANCES.remove(deps.storage, &deps.api.addr_validate(&address)?); + } + + Ok(Response::default().add_attribute("action", "update_allowances")) +} + +pub fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + dao: Option, + member_allowance: Option, +) -> Result { + // Check if sender is the owner. + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + let mut config = CONFIG.load(deps.storage)?; + + // Update if provided. + if let Some(dao) = dao { + config.dao = deps.api.addr_validate(&dao)?; + } + + // Update if provided. + if let Some(member_allowance) = member_allowance { + config.member_allowance = member_allowance; + } + + CONFIG.save(deps.storage, &config)?; + + Ok(Response::default().add_attribute("action", "update_config")) +} + +pub fn execute_update_owner( + deps: DepsMut, + info: MessageInfo, + env: Env, + action: cw_ownable::Action, +) -> Result { + let ownership = cw_ownable::update_ownership(deps, &env.block, &info.sender, action)?; + Ok(Response::default().add_attributes(ownership.into_attributes())) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_json_binary(&ConfigResponse { + config: CONFIG.load(deps.storage)?, + }), + QueryMsg::ListAllowances { start_after, limit } => { + query_list_allowances(deps, start_after, limit) + } + QueryMsg::Allowance { address } => to_json_binary(&query_allowance(deps, address)?), + QueryMsg::Info {} => query_info(deps), + QueryMsg::Ownership {} => to_json_binary(&cw_ownable::get_ownership(deps.storage)?), + } +} + +pub fn query_list_allowances( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let addr = maybe_addr(deps.api, start_after)?; + let start = addr.as_ref().map(Bound::exclusive); + + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let allowances = ALLOWANCES + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| { + let (address, allowance) = item?; + Ok(AllowanceEntry { address, allowance }) + }) + .collect::>>()?; + + to_json_binary(&ListAllowancesResponse { allowances }) +} + +pub fn query_allowance(deps: Deps, address: String) -> StdResult { + let mut allowance = ALLOWANCES.may_load(deps.storage, &deps.api.addr_validate(&address)?)?; + let mut is_member_allowance = false; + + // If no allowance found, use member allowance if the address is a member. + if allowance.is_none() { + let config = CONFIG.load(deps.storage)?; + + // Check if address has voting power in the DAO. + let voting_power: VotingPowerAtHeightResponse = deps.querier.query_wasm_smart( + config.dao, + &DaoQueryMsg::VotingPowerAtHeight { + address: address.to_string(), + height: None, + }, + )?; + + // If member, use member allowance. + if !voting_power.power.is_zero() { + allowance = Some(config.member_allowance); + is_member_allowance = true; + } + } + + Ok(AllowanceResponse { + allowance: allowance.unwrap_or(Allowance::None), + is_member_allowance, + }) +} + +pub fn query_info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + to_json_binary(&dao_interface::voting::InfoResponse { info }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + // Set contract to version to latest + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::default()) +} diff --git a/contracts/external/dao-cw20-transfer-rules/src/error.rs b/contracts/external/dao-cw20-transfer-rules/src/error.rs new file mode 100644 index 000000000..a799820a5 --- /dev/null +++ b/contracts/external/dao-cw20-transfer-rules/src/error.rs @@ -0,0 +1,21 @@ +use cosmwasm_std::StdError; +use cw_ownable::OwnershipError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error(transparent)] + Ownable(#[from] OwnershipError), + + #[error("invalid DAO: {error}")] + InvalidDao { error: StdError }, + + #[error("Unauthorized: sender not allowed to send tokens")] + UnauthorizedSender {}, + + #[error("Unauthorized: recipient not allowed to receive tokens")] + UnauthorizedRecipient {}, +} diff --git a/contracts/external/dao-cw20-transfer-rules/src/lib.rs b/contracts/external/dao-cw20-transfer-rules/src/lib.rs new file mode 100644 index 000000000..d1800adbc --- /dev/null +++ b/contracts/external/dao-cw20-transfer-rules/src/lib.rs @@ -0,0 +1,11 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod tests; + +pub use crate::error::ContractError; diff --git a/contracts/external/dao-cw20-transfer-rules/src/msg.rs b/contracts/external/dao-cw20-transfer-rules/src/msg.rs new file mode 100644 index 000000000..da006fe27 --- /dev/null +++ b/contracts/external/dao-cw20-transfer-rules/src/msg.rs @@ -0,0 +1,103 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Addr; +use cw20_hooks::hooks::Cw20HookMsg; +use cw_ownable::cw_ownable_execute; + +use crate::state::{Allowance, Config}; + +#[cw_serde] +pub struct AllowanceUpdate { + /// The address to set the allowance for. + pub address: String, + /// The allowance to set. + pub allowance: Allowance, +} + +#[cw_serde] +pub struct InstantiateMsg { + /// The address that can update the config and allowances. + pub owner: Option, + /// An initial list of allowances allowances. + pub allowances: Option>, + /// The DAO whose members may be able to send and/or receive tokens. + pub dao: String, + /// The allowance assigned to DAO members with no explicit allowance set. If + /// None, members with no allowance set cannot send nor receive tokens. + pub member_allowance: Option, +} + +#[cw_ownable_execute] +#[cw_serde] +pub enum ExecuteMsg { + Cw20Hook(Cw20HookMsg), + UpdateAllowances { + /// Addresses to add/update allowances for. + set: Vec, + /// Addresses to remove allowances from. + remove: Vec, + }, + UpdateConfig { + /// The DAO whose members may be able to send or receive tokens. + dao: Option, + /// The allowance assigned to DAO members with no explicit allowance + /// set. If None, members with no allowance set cannot send nor receive + /// tokens. + member_allowance: Option, + }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Returns the paginated list of allowances. + #[returns(ListAllowancesResponse)] + ListAllowances { + /// The address to start after. + start_after: Option, + limit: Option, + }, + /// Returns the config. + #[returns(ConfigResponse)] + Config {}, + /// Returns allowance for an address. + #[returns(AllowanceResponse)] + Allowance { address: String }, + /// Returns contract info. + #[returns(dao_interface::voting::InfoResponse)] + Info {}, + /// Returns info about the contract ownership. + #[returns(cw_ownable::Ownership)] + Ownership {}, +} + +#[cw_serde] +pub struct ConfigResponse { + /// The config. + pub config: Config, +} + +#[cw_serde] +pub struct AllowanceEntry { + /// The address. + pub address: Addr, + /// The allowance. + pub allowance: Allowance, +} + +#[cw_serde] +pub struct ListAllowancesResponse { + /// The allowances. + pub allowances: Vec, +} + +#[cw_serde] +pub struct AllowanceResponse { + /// The allowance. + pub allowance: Allowance, + /// Whether or not the allowance came from the member allowance fallback in + /// the config. + pub is_member_allowance: bool, +} + +#[cw_serde] +pub struct MigrateMsg {} diff --git a/contracts/external/dao-cw20-transfer-rules/src/state.rs b/contracts/external/dao-cw20-transfer-rules/src/state.rs new file mode 100644 index 000000000..5bc658c06 --- /dev/null +++ b/contracts/external/dao-cw20-transfer-rules/src/state.rs @@ -0,0 +1,37 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Addr; +use cw_storage_plus::{Item, Map}; + +#[cw_serde] +pub enum Allowance { + /// The address cannot send nor receive tokens. + None, + /// The address can send tokens to allowed recipients. + Send, + /// The address can send tokens to anyone, regardless of allowance. + SendAnywhere, + /// The address can receive tokens from allowed senders. + Receive, + /// The address can receive tokens from anyone, regardless of allowance. + ReceiveAnywhere, + /// The address can send/receive tokens to/from allowed recipients/senders. + SendAndReceive, + /// The address can send/receive tokens to/from anyone, regardless of + /// allowance. + SendAndReceiveAnywhere, +} + +#[cw_serde] +pub struct Config { + /// The DAO whose members may be able to send or receive tokens. + pub dao: Addr, + /// The allowance assigned to DAO members with no explicit allowance set. If + /// None, members with no allowance set cannot send nor receive tokens. + pub member_allowance: Allowance, +} + +/// Config +pub const CONFIG: Item = Item::new("config"); + +/// Addresses with allowances that permit them to send, receive, or both. +pub const ALLOWANCES: Map<&Addr, Allowance> = Map::new("allowances"); diff --git a/contracts/external/dao-cw20-transfer-rules/src/tests.rs b/contracts/external/dao-cw20-transfer-rules/src/tests.rs new file mode 100644 index 000000000..aa86b81a2 --- /dev/null +++ b/contracts/external/dao-cw20-transfer-rules/src/tests.rs @@ -0,0 +1,826 @@ +use cosmwasm_std::{to_json_binary, Addr, Decimal, Empty, Uint128}; +use cw20::Cw20Coin; +use cw_multi_test::{next_block, App, Contract, ContractWrapper, Executor}; +use cw_ownable::{Ownership, OwnershipError}; +use dao_interface::{ + msg::QueryMsg as DaoQueryMsg, + state::{Admin, ModuleInstantiateInfo}, + voting::Query as VotingQueryMsg, +}; +use dao_testing::contracts::{ + cw20_stake_contract, cw20_staked_balances_voting_contract, dao_dao_contract, + pre_propose_single_contract, proposal_single_contract, +}; +use dao_voting::{ + deposit::{DepositRefundPolicy, DepositToken, UncheckedDepositInfo, VotingModuleTokenType}, + pre_propose::PreProposeInfo, + threshold::PercentageThreshold, + threshold::Threshold, +}; + +use cw20_hooks::ContractError as Cw20HooksContractError; + +use crate::{ + msg::{ + AllowanceEntry, AllowanceResponse, AllowanceUpdate, ConfigResponse, ExecuteMsg, + InstantiateMsg, ListAllowancesResponse, QueryMsg, + }, + state::{Allowance, Config}, + ContractError, +}; + +fn dao_cw20_transfer_rules_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ); + Box::new(contract) +} + +fn cw20_hooks_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_hooks::contract::execute, + cw20_hooks::contract::instantiate, + cw20_hooks::contract::query, + ) + .with_reply(cw20_hooks::contract::reply) + .with_migrate(cw20_hooks::contract::migrate); + Box::new(contract) +} + +const ADDR1: &str = "addr1"; +const ADDR2: &str = "addr2"; +const ADDR3: &str = "addr3"; +const ALLOWED: &str = "allowed"; +const RANDOM: &str = "random"; + +fn setup_cw20_dao(app: &mut App) -> (Addr, Addr, Addr, Addr) { + let cw20_hooks_code_id = app.store_code(cw20_hooks_contract()); + let dao_dao_core_id = app.store_code(dao_dao_contract()); + let prop_single_id = app.store_code(proposal_single_contract()); + let pre_propose_single_id = app.store_code(pre_propose_single_contract()); + let cw20_stake_id = app.store_code(cw20_stake_contract()); + let cw20_voting_code_id = app.store_code(cw20_staked_balances_voting_contract()); + let dao_cw20_transfer_rules_code_id = app.store_code(dao_cw20_transfer_rules_contract()); + + let initial_balances = vec![ + Cw20Coin { + address: ADDR1.to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: ADDR2.to_string(), + amount: Uint128::new(100_000_000), + }, + Cw20Coin { + address: ADDR3.to_string(), + amount: Uint128::new(100_000_000), + }, + ]; + + let msg = dao_interface::msg::InstantiateMsg { + admin: None, + name: "DAO DAO".to_string(), + description: "A DAO that makes DAO tooling".to_string(), + image_url: Some("https://zmedley.com/raw_logo.png".to_string()), + dao_uri: None, + automatically_add_cw20s: false, + automatically_add_cw721s: false, + voting_module_instantiate_info: ModuleInstantiateInfo { + code_id: cw20_voting_code_id, + msg: to_json_binary(&dao_voting_cw20_staked::msg::InstantiateMsg { + token_info: dao_voting_cw20_staked::msg::TokenInfo::New { + code_id: cw20_hooks_code_id, + label: "DAO DAO Gov token".to_string(), + name: "DAO".to_string(), + symbol: "DAO".to_string(), + decimals: 6, + initial_balances: initial_balances.clone(), + marketing: None, + staking_code_id: cw20_stake_id, + unstaking_duration: Some(cw_utils::Duration::Time(1209600)), + initial_dao_balance: Some(Uint128::new(1000000000)), + }, + active_threshold: None, + }) + .unwrap(), + funds: vec![], + admin: Some(Admin::CoreModule {}), + label: "DAO DAO Voting Module".to_string(), + }, + proposal_modules_instantiate_info: vec![ModuleInstantiateInfo { + code_id: prop_single_id, + msg: to_json_binary(&dao_proposal_single::msg::InstantiateMsg { + min_voting_period: None, + threshold: Threshold::ThresholdQuorum { + threshold: PercentageThreshold::Majority {}, + quorum: PercentageThreshold::Percent(Decimal::percent(10)), + }, + max_voting_period: cw_utils::Duration::Time(432000), + allow_revoting: false, + only_members_execute: true, + pre_propose_info: PreProposeInfo::ModuleMayPropose { + info: ModuleInstantiateInfo { + code_id: pre_propose_single_id, + msg: to_json_binary(&dao_pre_propose_single::InstantiateMsg { + deposit_info: Some(UncheckedDepositInfo { + denom: DepositToken::VotingModuleToken { + token_type: VotingModuleTokenType::Cw20, + }, + amount: Uint128::new(1000000000), + refund_policy: DepositRefundPolicy::OnlyPassed, + }), + open_proposal_submission: false, + extension: Empty::default(), + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + funds: vec![], + label: "DAO DAO Pre-Propose Module".to_string(), + }, + }, + close_proposal_on_execution_failure: false, + veto: None, + }) + .unwrap(), + admin: Some(Admin::CoreModule {}), + funds: vec![], + label: "DAO DAO Proposal Module".to_string(), + }], + initial_items: None, + }; + + // Instantiate DAO + let dao_addr = app + .instantiate_contract( + dao_dao_core_id, + Addr::unchecked(RANDOM), + &msg, + &[], + "Test DAO".to_string(), + None, + ) + .unwrap(); + + // Get DAO voting module addr + let voting_module_addr: Addr = app + .wrap() + .query_wasm_smart(dao_addr.clone(), &DaoQueryMsg::VotingModule {}) + .unwrap(); + + // Get staking contract addr + let staking_addr: Addr = app + .wrap() + .query_wasm_smart( + voting_module_addr.clone(), + &dao_voting_cw20_staked::msg::QueryMsg::StakingContract {}, + ) + .unwrap(); + + // Get DAO cw20 token addr + let cw20_addr: Addr = app + .wrap() + .query_wasm_smart( + voting_module_addr.clone(), + &VotingQueryMsg::TokenContract {}, + ) + .unwrap(); + + // Stake tokens in the DAO + for balance in initial_balances { + app.execute_contract( + Addr::unchecked(balance.address), + cw20_addr.clone(), + &cw20_hooks::msg::ExecuteMsg::Send { + contract: staking_addr.to_string(), + amount: Uint128::new(50_000), + msg: to_json_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + &[], + ) + .unwrap(); + } + + let transfer_rules_addr = app + .instantiate_contract( + dao_cw20_transfer_rules_code_id, + dao_addr.clone(), + &InstantiateMsg { + owner: Some(dao_addr.to_string()), + allowances: Some(vec![AllowanceUpdate { + address: dao_addr.to_string(), + allowance: Allowance::SendAndReceiveAnywhere, + }]), + dao: dao_addr.to_string(), + member_allowance: Some(Allowance::SendAndReceive), + }, + &[], + "dao-cw20-transfer-rules", + None, + ) + .unwrap(); + + // Update the block so staked balances take effect + app.update_block(next_block); + + (dao_addr, transfer_rules_addr, cw20_addr, staking_addr) +} + +#[test] +pub fn test_transfer_send_rules() { + let mut app = App::default(); + let (dao_addr, transfer_rules_addr, cw20_addr, staking_addr) = setup_cw20_dao(&mut app); + + // Can't add hook if not owner (DAO). + app.execute_contract( + Addr::unchecked(RANDOM), + cw20_addr.clone(), + &cw20_hooks::msg::ExecuteMsg::AddHook { + addr: transfer_rules_addr.to_string(), + }, + &[], + ) + .unwrap_err(); + + // Add hook. + app.execute_contract( + dao_addr.clone(), + cw20_addr.clone(), + &cw20_hooks::msg::ExecuteMsg::AddHook { + addr: transfer_rules_addr.to_string(), + }, + &[], + ) + .unwrap(); + + // Now that hook is added, members can't transfer to non-members. + let err: Cw20HooksContractError = app + .execute_contract( + Addr::unchecked(ADDR1), + cw20_addr.clone(), + &cw20_hooks::msg::ExecuteMsg::Transfer { + recipient: Addr::unchecked(RANDOM).to_string(), + amount: Uint128::new(100), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, Cw20HooksContractError::HookErrored { .. })); + + // Now that hook is added, members also can't send to the staking contract. + let err: Cw20HooksContractError = app + .execute_contract( + Addr::unchecked(ADDR1.to_string()), + cw20_addr.clone(), + &cw20_hooks::msg::ExecuteMsg::Send { + contract: staking_addr.to_string(), + amount: Uint128::new(50_000), + msg: to_json_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert!(matches!(err, Cw20HooksContractError::HookErrored { .. })); + + // Members can transfer to members. + app.execute_contract( + Addr::unchecked(ADDR1), + cw20_addr.clone(), + &cw20_hooks::msg::ExecuteMsg::Transfer { + recipient: Addr::unchecked(ADDR2).to_string(), + amount: Uint128::new(100), + }, + &[], + ) + .unwrap(); + + // Non-owner can't update allowances. + let err: ContractError = app + .execute_contract( + Addr::unchecked(ADDR1), + transfer_rules_addr.clone(), + &ExecuteMsg::UpdateAllowances { + set: vec![AllowanceUpdate { + address: ALLOWED.to_string(), + allowance: Allowance::SendAndReceive, + }], + remove: vec![], + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::Ownable(cw_ownable::OwnershipError::NotOwner) + ); + + // Add a new address to the allowances + app.execute_contract( + dao_addr.clone(), + transfer_rules_addr.clone(), + &ExecuteMsg::UpdateAllowances { + set: vec![AllowanceUpdate { + address: ALLOWED.to_string(), + allowance: Allowance::Receive, + }], + remove: vec![], + }, + &[], + ) + .unwrap(); + + // Member can now transfer to newly allowed address + app.execute_contract( + Addr::unchecked(ADDR1), + cw20_addr.clone(), + &cw20_hooks::msg::ExecuteMsg::Transfer { + recipient: Addr::unchecked(ALLOWED).to_string(), + amount: Uint128::new(100), + }, + &[], + ) + .unwrap(); + + // DAO can transfer to non-members + app.execute_contract( + dao_addr.clone(), + cw20_addr.clone(), + &cw20_hooks::msg::ExecuteMsg::Transfer { + recipient: Addr::unchecked(RANDOM).to_string(), + amount: Uint128::new(100), + }, + &[], + ) + .unwrap(); + + // Add staking contract allowance. + app.execute_contract( + dao_addr.clone(), + transfer_rules_addr.clone(), + &ExecuteMsg::UpdateAllowances { + set: vec![AllowanceUpdate { + address: staking_addr.to_string(), + allowance: Allowance::SendAndReceiveAnywhere, + }], + remove: vec![], + }, + &[], + ) + .unwrap(); + + // Stake with DAO. + app.execute_contract( + Addr::unchecked(RANDOM.to_string()), + cw20_addr.clone(), + &cw20_hooks::msg::ExecuteMsg::Send { + contract: staking_addr.to_string(), + amount: Uint128::new(50), + msg: to_json_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }, + &[], + ) + .unwrap(); + + // Update the block so staked balances take effect + app.update_block(next_block); + + // Once added to the DAO, new members can transfer to DAO members + app.execute_contract( + Addr::unchecked(ADDR1), + cw20_addr.clone(), + &cw20_hooks::msg::ExecuteMsg::Transfer { + recipient: Addr::unchecked(RANDOM).to_string(), + amount: Uint128::new(10), + }, + &[], + ) + .unwrap(); + app.execute_contract( + Addr::unchecked(RANDOM), + cw20_addr.clone(), + &cw20_hooks::msg::ExecuteMsg::Transfer { + recipient: Addr::unchecked(ADDR2).to_string(), + amount: Uint128::new(10), + }, + &[], + ) + .unwrap(); +} + +#[test] +pub fn test_instantiate_invalid_dao_fails() { + let mut app = App::default(); + let dao_cw20_transfer_rules_code_id = app.store_code(dao_cw20_transfer_rules_contract()); + + app.instantiate_contract( + dao_cw20_transfer_rules_code_id, + Addr::unchecked(ADDR1), + &InstantiateMsg { + owner: None, + allowances: None, + dao: ADDR2.to_string(), + member_allowance: None, + }, + &[], + "dao-cw20-transfer-rules", + None, + ) + .unwrap_err(); +} + +#[test] +pub fn test_add_remove_allowances() { + let mut app = App::default(); + let (dao_addr, transfer_rules_addr, _, _) = setup_cw20_dao(&mut app); + + // Ensure DAO allowance exists. + let allowances: ListAllowancesResponse = app + .wrap() + .query_wasm_smart( + transfer_rules_addr.clone(), + &QueryMsg::ListAllowances { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!( + allowances, + ListAllowancesResponse { + allowances: vec![AllowanceEntry { + address: dao_addr.clone(), + allowance: Allowance::SendAndReceiveAnywhere + }] + } + ); + + // Fail to add if not owner. + let err: ContractError = app + .execute_contract( + Addr::unchecked(ADDR1), + transfer_rules_addr.clone(), + &ExecuteMsg::UpdateAllowances { + set: vec![AllowanceUpdate { + address: ADDR1.to_string(), + allowance: Allowance::Send, + }], + remove: vec![], + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Ownable(OwnershipError::NotOwner)); + + // Add successfully if owner. + app.execute_contract( + dao_addr.clone(), + transfer_rules_addr.clone(), + &ExecuteMsg::UpdateAllowances { + set: vec![AllowanceUpdate { + address: ADDR1.to_string(), + allowance: Allowance::Send, + }], + remove: vec![], + }, + &[], + ) + .unwrap(); + + // Ensure new allowance added. + let allowances: ListAllowancesResponse = app + .wrap() + .query_wasm_smart( + transfer_rules_addr.clone(), + &QueryMsg::ListAllowances { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!( + allowances, + ListAllowancesResponse { + allowances: vec![ + AllowanceEntry { + address: Addr::unchecked(ADDR1), + allowance: Allowance::Send, + }, + AllowanceEntry { + address: dao_addr.clone(), + allowance: Allowance::SendAndReceiveAnywhere, + }, + ] + } + ); + + // Fail to remove if not owner. + let err: ContractError = app + .execute_contract( + Addr::unchecked(RANDOM), + transfer_rules_addr.clone(), + &ExecuteMsg::UpdateAllowances { + set: vec![], + remove: vec![ADDR1.to_string()], + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Ownable(OwnershipError::NotOwner {})); + + // Remove successfully if owner. + app.execute_contract( + dao_addr.clone(), + transfer_rules_addr.clone(), + &ExecuteMsg::UpdateAllowances { + set: vec![], + remove: vec![ADDR1.to_string()], + }, + &[], + ) + .unwrap(); + + // Ensure allowance removed. + let allowances: ListAllowancesResponse = app + .wrap() + .query_wasm_smart( + transfer_rules_addr.clone(), + &QueryMsg::ListAllowances { + start_after: None, + limit: None, + }, + ) + .unwrap(); + assert_eq!( + allowances, + ListAllowancesResponse { + allowances: vec![AllowanceEntry { + address: dao_addr.clone(), + allowance: Allowance::SendAndReceiveAnywhere + }] + } + ); + + // Remove owner. + app.execute_contract( + dao_addr.clone(), + transfer_rules_addr.clone(), + &ExecuteMsg::UpdateOwnership(cw_ownable::Action::RenounceOwnership {}), + &[], + ) + .unwrap(); + + // Owner can no longer add nor remove allowances. + let err: ContractError = app + .execute_contract( + dao_addr.clone(), + transfer_rules_addr.clone(), + &ExecuteMsg::UpdateAllowances { + set: vec![AllowanceUpdate { + address: ADDR1.to_string(), + allowance: Allowance::Send, + }], + remove: vec![dao_addr.to_string()], + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Ownable(OwnershipError::NoOwner {})); +} + +#[test] +pub fn test_update_config() { + let mut app = App::default(); + let (dao_addr, transfer_rules_addr, _, _) = setup_cw20_dao(&mut app); + + // Ensure config is set. + let config: ConfigResponse = app + .wrap() + .query_wasm_smart(transfer_rules_addr.clone(), &QueryMsg::Config {}) + .unwrap(); + assert_eq!( + config, + ConfigResponse { + config: Config { + dao: dao_addr.clone(), + member_allowance: Allowance::SendAndReceive, + } + } + ); + + // Fail to update config if not owner. + let err: ContractError = app + .execute_contract( + Addr::unchecked(RANDOM), + transfer_rules_addr.clone(), + &ExecuteMsg::UpdateConfig { + dao: None, + member_allowance: Some(Allowance::SendAndReceiveAnywhere), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Ownable(OwnershipError::NotOwner)); + + // Successfully update config if owner. + app.execute_contract( + dao_addr.clone(), + transfer_rules_addr.clone(), + &ExecuteMsg::UpdateConfig { + dao: Some("other_dao".to_string()), + member_allowance: Some(Allowance::SendAndReceiveAnywhere), + }, + &[], + ) + .unwrap(); + + // Ensure config is updated. + let config: ConfigResponse = app + .wrap() + .query_wasm_smart(transfer_rules_addr.clone(), &QueryMsg::Config {}) + .unwrap(); + assert_eq!( + config, + ConfigResponse { + config: Config { + dao: Addr::unchecked("other_dao"), + member_allowance: Allowance::SendAndReceiveAnywhere, + } + } + ); +} + +#[test] +pub fn test_ownership_transfer() { + let mut app = App::default(); + let (dao_addr, transfer_rules_addr, _, _) = setup_cw20_dao(&mut app); + + // Ensure owner is set. + let ownership: Ownership = app + .wrap() + .query_wasm_smart(transfer_rules_addr.clone(), &QueryMsg::Ownership {}) + .unwrap(); + assert_eq!( + ownership, + Ownership { + owner: Some(dao_addr.clone()), + pending_owner: None, + pending_expiry: None, + } + ); + + // Fail to transfer owner if not owner. + let err: ContractError = app + .execute_contract( + Addr::unchecked(RANDOM), + transfer_rules_addr.clone(), + &ExecuteMsg::UpdateOwnership(cw_ownable::Action::TransferOwnership { + new_owner: RANDOM.to_string(), + expiry: None, + }), + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::Ownable(cw_ownable::OwnershipError::NotOwner) + ); + + // Initiate transfer if owner. + app.execute_contract( + dao_addr.clone(), + transfer_rules_addr.clone(), + &ExecuteMsg::UpdateOwnership(cw_ownable::Action::TransferOwnership { + new_owner: ADDR3.to_string(), + expiry: None, + }), + &[], + ) + .unwrap(); + + // Accept transfer from new owner. + app.execute_contract( + Addr::unchecked(ADDR3), + transfer_rules_addr.clone(), + &ExecuteMsg::UpdateOwnership(cw_ownable::Action::AcceptOwnership {}), + &[], + ) + .unwrap(); + + // Ensure owner was transferred. + let ownership: Ownership = app + .wrap() + .query_wasm_smart(transfer_rules_addr.clone(), &QueryMsg::Ownership {}) + .unwrap(); + assert_eq!( + ownership, + Ownership { + owner: Some(Addr::unchecked(ADDR3)), + pending_owner: None, + pending_expiry: None, + } + ); +} + +#[test] +pub fn test_allowance_member_fallback() { + let mut app = App::default(); + let (dao_addr, transfer_rules_addr, _, _) = setup_cw20_dao(&mut app); + + // Ensure allowance for DAO member matches the configured allowance. + let allowance: AllowanceResponse = app + .wrap() + .query_wasm_smart( + transfer_rules_addr.clone(), + &QueryMsg::Allowance { + address: ADDR1.to_string(), + }, + ) + .unwrap(); + assert_eq!( + allowance, + AllowanceResponse { + allowance: Allowance::SendAndReceive, + is_member_allowance: true, + } + ); + + // Add allowance override for DAO member. + app.execute_contract( + dao_addr.clone(), + transfer_rules_addr.clone(), + &ExecuteMsg::UpdateAllowances { + set: vec![AllowanceUpdate { + address: ADDR1.to_string(), + allowance: Allowance::Send, + }], + remove: vec![], + }, + &[], + ) + .unwrap(); + + // Ensure allowance for DAO member uses override instead of member fallback. + let allowance: AllowanceResponse = app + .wrap() + .query_wasm_smart( + transfer_rules_addr.clone(), + &QueryMsg::Allowance { + address: ADDR1.to_string(), + }, + ) + .unwrap(); + assert_eq!( + allowance, + AllowanceResponse { + allowance: Allowance::Send, + is_member_allowance: false, + } + ); + + // Remove allowance override for DAO member. + app.execute_contract( + dao_addr.clone(), + transfer_rules_addr.clone(), + &ExecuteMsg::UpdateAllowances { + set: vec![], + remove: vec![ADDR1.to_string()], + }, + &[], + ) + .unwrap(); + + // Ensure allowance for DAO member matches the configured allowance again. + let allowance: AllowanceResponse = app + .wrap() + .query_wasm_smart( + transfer_rules_addr.clone(), + &QueryMsg::Allowance { + address: ADDR1.to_string(), + }, + ) + .unwrap(); + assert_eq!( + allowance, + AllowanceResponse { + allowance: Allowance::SendAndReceive, + is_member_allowance: true, + } + ); +} diff --git a/contracts/external/dao-migrator/Cargo.toml b/contracts/external/dao-migrator/Cargo.toml index 4d2b48cb8..a8db304ad 100644 --- a/contracts/external/dao-migrator/Cargo.toml +++ b/contracts/external/dao-migrator/Cargo.toml @@ -32,7 +32,7 @@ dao-proposal-single = { workspace = true, features = ["library"] } dao-voting-cw4 = { workspace = true, features = ["library"] } cw20-stake = { workspace = true, features = ["library"] } dao-voting-cw20-staked = { workspace = true, features = ["library"] } -cw20-base = { workspace = true, features = ["library"] } +cw20-hooks = { workspace = true, features = ["library"] } cw-utils-v1 = { workspace = true } voting-v1 = { workspace = true } diff --git a/contracts/external/dao-migrator/src/testing/helpers.rs b/contracts/external/dao-migrator/src/testing/helpers.rs index c25d13507..02c66c9dd 100644 --- a/contracts/external/dao-migrator/src/testing/helpers.rs +++ b/contracts/external/dao-migrator/src/testing/helpers.rs @@ -5,8 +5,8 @@ use cosmwasm_std::{ use cw_multi_test::{next_block, App, Contract, ContractWrapper, Executor}; use dao_interface::query::SubDao; use dao_testing::contracts::{ - cw20_base_contract, cw20_staked_balances_voting_contract, cw4_group_contract, dao_dao_contract, - proposal_single_contract, v1_dao_dao_contract, v1_proposal_single_contract, + cw20_hooks_contract, cw20_staked_balances_voting_contract, cw4_group_contract, + dao_dao_contract, proposal_single_contract, v1_dao_dao_contract, v1_proposal_single_contract, }; use crate::{ @@ -20,7 +20,7 @@ pub(crate) const SENDER_ADDR: &str = "creator"; pub struct CodeIds { pub core: u64, pub proposal_single: u64, - pub cw20_base: u64, + pub cw20_hooks: u64, pub cw20_stake: u64, pub cw20_voting: u64, pub cw4_group: u64, @@ -52,7 +52,7 @@ pub fn get_v1_code_ids(app: &mut App) -> (CodeIds, V1CodeIds) { let code_ids = CodeIds { core: app.store_code(v1_dao_dao_contract()), proposal_single: app.store_code(v1_proposal_single_contract()), - cw20_base: app.store_code(cw20_base_contract()), + cw20_hooks: app.store_code(cw20_hooks_contract()), cw20_stake: app.store_code(v1_cw20_stake_contract()), cw20_voting: app.store_code(cw20_staked_balances_voting_contract()), cw4_group: app.store_code(cw4_group_contract()), @@ -72,7 +72,7 @@ pub fn get_v2_code_ids(app: &mut App) -> (CodeIds, V2CodeIds) { let code_ids = CodeIds { core: app.store_code(dao_dao_contract()), proposal_single: app.store_code(proposal_single_contract()), - cw20_base: app.store_code(cw20_base_contract()), + cw20_hooks: app.store_code(cw20_hooks_contract()), cw20_stake: app.store_code(v2_cw20_stake_contract()), cw20_voting: app.store_code(dao_voting_cw20_staked_contract()), cw4_group: app.store_code(cw4_group_contract()), @@ -91,7 +91,7 @@ pub fn get_v2_code_ids(app: &mut App) -> (CodeIds, V2CodeIds) { pub fn get_cw20_init_msg(code_ids: CodeIds) -> cw20_staked_balance_voting_v1::msg::InstantiateMsg { cw20_staked_balance_voting_v1::msg::InstantiateMsg { token_info: cw20_staked_balance_voting_v1::msg::TokenInfo::New { - code_id: code_ids.cw20_base, + code_id: code_ids.cw20_hooks, label: "token".to_string(), name: "name".to_string(), symbol: "symbol".to_string(), diff --git a/contracts/proposal/dao-proposal-multiple/Cargo.toml b/contracts/proposal/dao-proposal-multiple/Cargo.toml index 5eca7e2df..a96765bc4 100644 --- a/contracts/proposal/dao-proposal-multiple/Cargo.toml +++ b/contracts/proposal/dao-proposal-multiple/Cargo.toml @@ -50,8 +50,8 @@ dao-voting-token-staked = { workspace = true } dao-voting-cw721-staked = { workspace = true } cw-denom = { workspace = true } dao-testing = { workspace = true } +cw20-hooks = { workspace = true } cw20-stake = { workspace = true } -cw20-base = { workspace = true } cw721-base = { workspace = true } cw4 = { workspace = true } cw4-group = { workspace = true } diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs b/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs index 7a31d74e5..58194aace 100644 --- a/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs +++ b/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs @@ -163,7 +163,7 @@ where app.execute_contract( Addr::unchecked(&proposer), token.clone(), - &cw20_base::msg::ExecuteMsg::IncreaseAllowance { + &cw20_hooks::msg::ExecuteMsg::IncreaseAllowance { spender: pre_propose_module.to_string(), amount, expires: None, diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/instantiate.rs b/contracts/proposal/dao-proposal-multiple/src/testing/instantiate.rs index 8fd7e0c95..681620c69 100644 --- a/contracts/proposal/dao-proposal-multiple/src/testing/instantiate.rs +++ b/contracts/proposal/dao-proposal-multiple/src/testing/instantiate.rs @@ -5,7 +5,7 @@ use cw_utils::Duration; use dao_interface::state::{Admin, ModuleInstantiateInfo}; use dao_pre_propose_multiple as cppm; use dao_testing::contracts::{ - cw20_balances_voting_contract, cw20_base_contract, cw20_stake_contract, + cw20_balances_voting_contract, cw20_hooks_contract, cw20_stake_contract, cw20_staked_balances_voting_contract, cw4_group_contract, cw721_base_contract, dao_dao_contract, native_staked_balances_voting_contract, pre_propose_multiple_contract, }; @@ -348,7 +348,7 @@ pub fn instantiate_with_cw20_balances_governance( ) -> Addr { let proposal_module_code_id = app.store_code(proposal_multiple_contract()); - let cw20_id = app.store_code(cw20_base_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let core_id = app.store_code(dao_dao_contract()); let votemod_id = app.store_code(cw20_balances_voting_contract()); @@ -452,7 +452,7 @@ pub fn instantiate_with_staked_balances_governance( .collect() }; - let cw20_id = app.store_code(cw20_base_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let cw20_stake_id = app.store_code(cw20_stake_contract()); let staked_balances_voting_id = app.store_code(cw20_staked_balances_voting_contract()); let core_contract_id = app.store_code(dao_dao_contract()); @@ -589,7 +589,7 @@ pub fn instantiate_with_multiple_staked_balances_governance( .collect() }; - let cw20_id = app.store_code(cw20_base_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let cw20_stake_id = app.store_code(cw20_stake_contract()); let staked_balances_voting_id = app.store_code(cw20_staked_balances_voting_contract()); let core_contract_id = app.store_code(dao_dao_contract()); @@ -699,7 +699,7 @@ pub fn instantiate_with_staking_active_threshold( active_threshold: Option, ) -> Addr { let proposal_module_code_id = app.store_code(proposal_multiple_contract()); - let cw20_id = app.store_code(cw20_base_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let cw20_staking_id = app.store_code(cw20_stake_contract()); let core_id = app.store_code(dao_dao_contract()); let votemod_id = app.store_code(cw20_staked_balances_voting_contract()); diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs b/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs index d7b51beaa..0de3676bb 100644 --- a/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs +++ b/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs @@ -53,7 +53,7 @@ use crate::{ use dao_pre_propose_multiple as cppm; use dao_testing::{ - contracts::{cw20_balances_voting_contract, cw20_base_contract}, + contracts::{cw20_balances_voting_contract, cw20_hooks_contract}, ShouldExecute, }; @@ -1019,18 +1019,19 @@ fn test_voting_module_token_proposal_deposit_instantiate() { fn test_different_token_proposal_deposit() { let mut app = App::default(); let _govmod_id = app.store_code(proposal_multiple_contract()); - let cw20_id = app.store_code(cw20_base_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let cw20_addr = app .instantiate_contract( cw20_id, Addr::unchecked(CREATOR_ADDR), - &cw20_base::msg::InstantiateMsg { + &cw20_hooks::msg::InstantiateMsg { name: "OAD OAD".to_string(), symbol: "OAD".to_string(), decimals: 6, initial_balances: vec![], mint: None, marketing: None, + owner: None, }, &[], "random-cw20", @@ -1073,7 +1074,7 @@ fn test_different_token_proposal_deposit() { fn test_bad_token_proposal_deposit() { let mut app = App::default(); let _govmod_id = app.store_code(proposal_multiple_contract()); - let cw20_id = app.store_code(cw20_base_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let votemod_id = app.store_code(cw20_balances_voting_contract()); let votemod_addr = app @@ -1216,7 +1217,7 @@ fn test_take_proposal_deposit() { app.execute_contract( Addr::unchecked("blue"), Addr::unchecked(token), - &cw20_base::msg::ExecuteMsg::IncreaseAllowance { + &cw20_hooks::msg::ExecuteMsg::IncreaseAllowance { spender: govmod.to_string(), amount: Uint128::new(1), expires: None, @@ -1840,7 +1841,7 @@ fn test_cant_propose_zero_power() { app.execute_contract( Addr::unchecked("blue"), token.clone(), - &cw20_base::msg::ExecuteMsg::IncreaseAllowance { + &cw20_hooks::msg::ExecuteMsg::IncreaseAllowance { spender: pre_propose_module.to_string(), amount, expires: None, @@ -4318,7 +4319,7 @@ fn test_no_double_refund_on_execute_fail_and_close() { app.execute_contract( Addr::unchecked(CREATOR_ADDR), token_contract.clone(), - &cw20_base::msg::ExecuteMsg::IncreaseAllowance { + &cw20_hooks::msg::ExecuteMsg::IncreaseAllowance { spender: govmod.to_string(), amount: Uint128::new(1), expires: None, diff --git a/contracts/proposal/dao-proposal-single/Cargo.toml b/contracts/proposal/dao-proposal-single/Cargo.toml index 06170dd00..0c8c975ae 100644 --- a/contracts/proposal/dao-proposal-single/Cargo.toml +++ b/contracts/proposal/dao-proposal-single/Cargo.toml @@ -31,7 +31,7 @@ dao-pre-propose-base = { workspace = true } dao-voting = { workspace = true } thiserror = { workspace = true } -cw-utils-v1 = { workspace = true} +cw-utils-v1 = { workspace = true } voting-v1 = { workspace = true } cw-proposal-single-v1 = { workspace = true, features = ["library"] } diff --git a/contracts/proposal/dao-proposal-single/src/testing/instantiate.rs b/contracts/proposal/dao-proposal-single/src/testing/instantiate.rs index 020154700..dab828738 100644 --- a/contracts/proposal/dao-proposal-single/src/testing/instantiate.rs +++ b/contracts/proposal/dao-proposal-single/src/testing/instantiate.rs @@ -6,6 +6,7 @@ use cw_utils::Duration; use dao_interface::state::{Admin, ModuleInstantiateInfo}; use dao_pre_propose_single as cppbps; +use dao_testing::contracts::cw20_hooks_contract; use dao_voting::{ deposit::{DepositRefundPolicy, UncheckedDepositInfo, VotingModuleTokenType}, pre_propose::PreProposeInfo, @@ -17,9 +18,9 @@ use crate::msg::InstantiateMsg; use super::{ contracts::{ - cw20_base_contract, cw20_stake_contract, cw20_staked_balances_voting_contract, - cw4_group_contract, cw4_voting_contract, cw721_base_contract, cw721_stake_contract, - cw_core_contract, native_staked_balances_voting_contract, proposal_single_contract, + cw20_stake_contract, cw20_staked_balances_voting_contract, cw4_group_contract, + cw4_voting_contract, cw721_base_contract, cw721_stake_contract, cw_core_contract, + native_staked_balances_voting_contract, proposal_single_contract, }, CREATOR_ADDR, }; @@ -367,7 +368,7 @@ pub(crate) fn instantiate_with_staked_balances_governance( .collect() }; - let cw20_id = app.store_code(cw20_base_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let cw20_stake_id = app.store_code(cw20_stake_contract()); let staked_balances_voting_id = app.store_code(cw20_staked_balances_voting_contract()); let core_contract_id = app.store_code(cw_core_contract()); @@ -475,7 +476,7 @@ pub(crate) fn instantiate_with_staking_active_threshold( active_threshold: Option, ) -> Addr { let proposal_module_code_id = app.store_code(proposal_single_contract()); - let cw20_id = app.store_code(cw20_base_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let cw20_staking_id = app.store_code(cw20_stake_contract()); let core_id = app.store_code(cw_core_contract()); let votemod_id = app.store_code(cw20_staked_balances_voting_contract()); diff --git a/contracts/proposal/dao-proposal-single/src/testing/migration_tests.rs b/contracts/proposal/dao-proposal-single/src/testing/migration_tests.rs index 83196d8cf..463647d0c 100644 --- a/contracts/proposal/dao-proposal-single/src/testing/migration_tests.rs +++ b/contracts/proposal/dao-proposal-single/src/testing/migration_tests.rs @@ -4,7 +4,7 @@ use cw_multi_test::{next_block, App, Executor}; use cw_utils::Duration; use dao_interface::query::{GetItemResponse, ProposalModuleCountResponse}; use dao_testing::contracts::{ - cw20_base_contract, cw20_stake_contract, cw20_staked_balances_voting_contract, + cw20_hooks_contract, cw20_stake_contract, cw20_staked_balances_voting_contract, dao_dao_contract, proposal_single_contract, v1_dao_dao_contract, v1_proposal_single_contract, }; use dao_voting::veto::VetoConfig; @@ -52,7 +52,7 @@ fn test_v1_v2_full_migration() { // cw20 staking and voting module has not changed across v1->v2 so // we use the current edition. - let cw20_code = app.store_code(cw20_base_contract()); + let cw20_code = app.store_code(cw20_hooks_contract()); let cw20_stake_code = app.store_code(cw20_stake_contract()); let voting_code = app.store_code(cw20_staked_balances_voting_contract()); diff --git a/contracts/proposal/dao-proposal-single/src/testing/tests.rs b/contracts/proposal/dao-proposal-single/src/testing/tests.rs index 4246e5a09..7b8fb2fbd 100644 --- a/contracts/proposal/dao-proposal-single/src/testing/tests.rs +++ b/contracts/proposal/dao-proposal-single/src/testing/tests.rs @@ -3152,7 +3152,7 @@ pub fn test_migrate_updates_version() { // address: CREATOR_ADDR.to_string(), // }]; -// let cw20_id = app.store_code(cw20_base_contract()); +// let cw20_id = app.store_code(cw20_hooks_contract()); // let cw20_stake_id = app.store_code(cw20_stake_contract()); // let staked_balances_voting_id = app.store_code(cw20_staked_balances_voting_contract()); // let core_contract_id = app.store_code(cw_core_contract()); diff --git a/contracts/test/dao-proposal-hook-counter/Cargo.toml b/contracts/test/dao-proposal-hook-counter/Cargo.toml index 395fb338a..e7c8c809a 100644 --- a/contracts/test/dao-proposal-hook-counter/Cargo.toml +++ b/contracts/test/dao-proposal-hook-counter/Cargo.toml @@ -34,4 +34,5 @@ dao-voting = { workspace = true } dao-interface = { workspace = true } dao-dao-core = { workspace = true } dao-proposal-single = { workspace = true } +dao-testing = { workspace = true } cw-multi-test = { workspace = true } diff --git a/contracts/test/dao-proposal-hook-counter/src/tests.rs b/contracts/test/dao-proposal-hook-counter/src/tests.rs index 611c22dad..f536a5846 100644 --- a/contracts/test/dao-proposal-hook-counter/src/tests.rs +++ b/contracts/test/dao-proposal-hook-counter/src/tests.rs @@ -5,6 +5,7 @@ use cw_multi_test::{App, Contract, ContractWrapper, Executor}; use dao_interface::state::ProposalModule; use dao_interface::state::{Admin, ModuleInstantiateInfo}; +use dao_testing::contracts::cw20_hooks_contract; use dao_voting::{ pre_propose::PreProposeInfo, threshold::{PercentageThreshold, Threshold}, @@ -17,15 +18,6 @@ use dao_voting::proposal::SingleChoiceProposeMsg as ProposeMsg; const CREATOR_ADDR: &str = "creator"; -fn cw20_contract() -> Box> { - let contract = ContractWrapper::new( - cw20_base::contract::execute, - cw20_base::contract::instantiate, - cw20_base::contract::query, - ); - Box::new(contract) -} - fn single_govmod_contract() -> Box> { let contract = ContractWrapper::new( dao_proposal_single::contract::execute, @@ -87,7 +79,7 @@ fn instantiate_with_default_governance( msg: dao_proposal_single::msg::InstantiateMsg, initial_balances: Option>, ) -> Addr { - let cw20_id = app.store_code(cw20_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let governance_id = app.store_code(cw_gov_contract()); let votemod_id = app.store_code(cw20_balances_voting()); diff --git a/contracts/test/dao-voting-cw20-balance/Cargo.toml b/contracts/test/dao-voting-cw20-balance/Cargo.toml index f126b6840..a241bca16 100644 --- a/contracts/test/dao-voting-cw20-balance/Cargo.toml +++ b/contracts/test/dao-voting-cw20-balance/Cargo.toml @@ -26,7 +26,7 @@ cw-utils = { workspace = true } thiserror = { workspace = true } dao-dao-macros = { workspace = true } dao-interface = { workspace = true } -cw20-base = { workspace = true, features = ["library"] } +cw20-hooks = { workspace = true, features = ["library"] } [dev-dependencies] cw-multi-test = { workspace = true } diff --git a/contracts/test/dao-voting-cw20-balance/src/contract.rs b/contracts/test/dao-voting-cw20-balance/src/contract.rs index 6d75355fd..536aaed43 100644 --- a/contracts/test/dao-voting-cw20-balance/src/contract.rs +++ b/contracts/test/dao-voting-cw20-balance/src/contract.rs @@ -55,7 +55,8 @@ pub fn instantiate( let msg = WasmMsg::Instantiate { admin: Some(info.sender.to_string()), code_id, - msg: to_json_binary(&cw20_base::msg::InstantiateMsg { + msg: to_json_binary(&cw20_hooks::msg::InstantiateMsg { + owner: Some(info.sender.to_string()), name, symbol, decimals, diff --git a/contracts/test/dao-voting-cw20-balance/src/msg.rs b/contracts/test/dao-voting-cw20-balance/src/msg.rs index c41590ee2..f3463cf25 100644 --- a/contracts/test/dao-voting-cw20-balance/src/msg.rs +++ b/contracts/test/dao-voting-cw20-balance/src/msg.rs @@ -1,7 +1,7 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; use cw20::Cw20Coin; -use cw20_base::msg::InstantiateMarketingInfo; +use cw20_hooks::msg::InstantiateMarketingInfo; use dao_dao_macros::{cw20_token_query, voting_module_query}; diff --git a/contracts/test/dao-voting-cw20-balance/src/tests.rs b/contracts/test/dao-voting-cw20-balance/src/tests.rs index 4560fd5ea..403140d51 100644 --- a/contracts/test/dao-voting-cw20-balance/src/tests.rs +++ b/contracts/test/dao-voting-cw20-balance/src/tests.rs @@ -11,9 +11,9 @@ const CREATOR_ADDR: &str = "creator"; fn cw20_contract() -> Box> { let contract = ContractWrapper::new( - cw20_base::contract::execute, - cw20_base::contract::instantiate, - cw20_base::contract::query, + cw20_hooks::contract::execute, + cw20_hooks::contract::instantiate, + cw20_hooks::contract::query, ); Box::new(contract) } @@ -264,7 +264,7 @@ fn test_existing_cw20() { .instantiate_contract( cw20_id, Addr::unchecked(CREATOR_ADDR), - &cw20_base::msg::InstantiateMsg { + &cw20_hooks::msg::InstantiateMsg { name: "DAO DAO".to_string(), symbol: "DAO".to_string(), decimals: 3, @@ -274,6 +274,7 @@ fn test_existing_cw20() { }], mint: None, marketing: None, + owner: None, }, &[], "voting token", diff --git a/contracts/voting/dao-voting-cw20-staked/Cargo.toml b/contracts/voting/dao-voting-cw20-staked/Cargo.toml index 4eae84a64..6a9e6584e 100644 --- a/contracts/voting/dao-voting-cw20-staked/Cargo.toml +++ b/contracts/voting/dao-voting-cw20-staked/Cargo.toml @@ -23,7 +23,7 @@ cw-storage-plus = { workspace = true } cw-utils = { workspace = true } cw2 = { workspace = true } cw20 = { workspace = true } -cw20-base = { workspace = true, features = ["library"] } +cw20-hooks = { workspace = true, features = ["library"] } cw20-stake = { workspace = true, features = ["library"] } thiserror = { workspace = true } dao-dao-macros = { workspace = true } diff --git a/contracts/voting/dao-voting-cw20-staked/schema/dao-voting-cw20-staked.json b/contracts/voting/dao-voting-cw20-staked/schema/dao-voting-cw20-staked.json index a2e254e93..f40cac67d 100644 --- a/contracts/voting/dao-voting-cw20-staked/schema/dao-voting-cw20-staked.json +++ b/contracts/voting/dao-voting-cw20-staked/schema/dao-voting-cw20-staked.json @@ -344,7 +344,7 @@ ], "properties": { "code_id": { - "description": "Code ID for cw20 token contract.", + "description": "Code ID for cw20-hooks token contract.", "type": "integer", "format": "uint64", "minimum": 0.0 diff --git a/contracts/voting/dao-voting-cw20-staked/src/contract.rs b/contracts/voting/dao-voting-cw20-staked/src/contract.rs index 47913a57c..30fd6eb4a 100644 --- a/contracts/voting/dao-voting-cw20-staked/src/contract.rs +++ b/contracts/voting/dao-voting-cw20-staked/src/contract.rs @@ -141,7 +141,8 @@ pub fn instantiate( let msg = WasmMsg::Instantiate { admin: Some(info.sender.to_string()), code_id, - msg: to_json_binary(&cw20_base::msg::InstantiateMsg { + msg: to_json_binary(&cw20_hooks::msg::InstantiateMsg { + owner: Some(info.sender.to_string()), name, symbol, decimals, @@ -175,7 +176,7 @@ pub fn assert_valid_absolute_count_threshold( } let token_info: cw20::TokenInfoResponse = deps .querier - .query_wasm_smart(token_addr, &cw20_base::msg::QueryMsg::TokenInfo {})?; + .query_wasm_smart(token_addr, &cw20_hooks::msg::QueryMsg::TokenInfo {})?; if count > token_info.total_supply { return Err(ContractError::InvalidAbsoluteCount {}); } @@ -339,7 +340,7 @@ pub fn query_is_active(deps: Deps) -> StdResult { // for coming to my ted talk. let total_potential_power: TokenInfoResponse = deps .querier - .query_wasm_smart(token_contract, &cw20_base::msg::QueryMsg::TokenInfo {})?; + .query_wasm_smart(token_contract, &cw20_hooks::msg::QueryMsg::TokenInfo {})?; let total_power = total_potential_power .total_supply .full_mul(PRECISION_FACTOR); diff --git a/contracts/voting/dao-voting-cw20-staked/src/msg.rs b/contracts/voting/dao-voting-cw20-staked/src/msg.rs index bdb5e9f2a..db67e68be 100644 --- a/contracts/voting/dao-voting-cw20-staked/src/msg.rs +++ b/contracts/voting/dao-voting-cw20-staked/src/msg.rs @@ -1,7 +1,7 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; use cosmwasm_std::Uint128; use cw20::Cw20Coin; -use cw20_base::msg::InstantiateMarketingInfo; +use cw20_hooks::msg::InstantiateMarketingInfo; use cw_utils::Duration; use dao_dao_macros::{active_query, cw20_token_query, voting_module_query}; @@ -35,7 +35,7 @@ pub enum TokenInfo { staking_contract: StakingInfo, }, New { - /// Code ID for cw20 token contract. + /// Code ID for cw20-hooks token contract. code_id: u64, /// Label to use for instantiated cw20 contract. label: String, diff --git a/contracts/voting/dao-voting-cw20-staked/src/tests.rs b/contracts/voting/dao-voting-cw20-staked/src/tests.rs index 0022f98ed..0ca85cc56 100644 --- a/contracts/voting/dao-voting-cw20-staked/src/tests.rs +++ b/contracts/voting/dao-voting-cw20-staked/src/tests.rs @@ -18,9 +18,9 @@ const CREATOR_ADDR: &str = "creator"; fn cw20_contract() -> Box> { let contract = ContractWrapper::new( - cw20_base::contract::execute, - cw20_base::contract::instantiate, - cw20_base::contract::query, + cw20_hooks::contract::execute, + cw20_hooks::contract::instantiate, + cw20_hooks::contract::query, ); Box::new(contract) } @@ -382,7 +382,7 @@ fn test_existing_cw20_new_staking() { .instantiate_contract( cw20_id, Addr::unchecked(CREATOR_ADDR), - &cw20_base::msg::InstantiateMsg { + &cw20_hooks::msg::InstantiateMsg { name: "DAO DAO".to_string(), symbol: "DAO".to_string(), decimals: 3, @@ -392,6 +392,7 @@ fn test_existing_cw20_new_staking() { }], mint: None, marketing: None, + owner: None, }, &[], "voting token", @@ -533,7 +534,7 @@ fn test_existing_cw20_existing_staking() { .instantiate_contract( cw20_id, Addr::unchecked(CREATOR_ADDR), - &cw20_base::msg::InstantiateMsg { + &cw20_hooks::msg::InstantiateMsg { name: "DAO DAO".to_string(), symbol: "DAO".to_string(), decimals: 3, @@ -543,6 +544,7 @@ fn test_existing_cw20_existing_staking() { }], mint: None, marketing: None, + owner: None, }, &[], "voting token", @@ -686,7 +688,7 @@ fn test_existing_cw20_existing_staking() { .instantiate_contract( cw20_id, Addr::unchecked(CREATOR_ADDR), - &cw20_base::msg::InstantiateMsg { + &cw20_hooks::msg::InstantiateMsg { name: "DAO DAO MISMATCH".to_string(), symbol: "DAOM".to_string(), decimals: 3, @@ -696,6 +698,7 @@ fn test_existing_cw20_existing_staking() { }], mint: None, marketing: None, + owner: None, }, &[], "voting token", @@ -734,7 +737,7 @@ fn test_different_heights() { .instantiate_contract( cw20_id, Addr::unchecked(CREATOR_ADDR), - &cw20_base::msg::InstantiateMsg { + &cw20_hooks::msg::InstantiateMsg { name: "DAO DAO".to_string(), symbol: "DAO".to_string(), decimals: 3, @@ -744,6 +747,7 @@ fn test_different_heights() { }], mint: None, marketing: None, + owner: None, }, &[], "voting token", diff --git a/packages/dao-testing/Cargo.toml b/packages/dao-testing/Cargo.toml index 51d0685b4..7d07de989 100644 --- a/packages/dao-testing/Cargo.toml +++ b/packages/dao-testing/Cargo.toml @@ -38,6 +38,7 @@ cw-core-v1 = { workspace = true, features = ["library"] } cw-hooks = { workspace = true } cw-proposal-single-v1 = { workspace = true } cw-vesting = { workspace = true } +cw20-hooks = { workspace = true } cw20-stake = { workspace = true } cw721-base = { workspace = true } cw721-roles = { workspace = true } diff --git a/packages/dao-testing/src/contracts.rs b/packages/dao-testing/src/contracts.rs index a0418a48f..a7efe8445 100644 --- a/packages/dao-testing/src/contracts.rs +++ b/packages/dao-testing/src/contracts.rs @@ -13,6 +13,17 @@ pub fn cw20_base_contract() -> Box> { Box::new(contract) } +pub fn cw20_hooks_contract() -> Box> { + let contract = ContractWrapper::new( + cw20_hooks::contract::execute, + cw20_hooks::contract::instantiate, + cw20_hooks::contract::query, + ) + .with_reply(cw20_hooks::contract::reply) + .with_migrate(cw20_hooks::contract::migrate); + Box::new(contract) +} + pub fn cw4_group_contract() -> Box> { let contract = ContractWrapper::new( cw4_group::contract::execute, diff --git a/packages/dao-testing/src/helpers.rs b/packages/dao-testing/src/helpers.rs index b629e7ba0..8af52f4eb 100644 --- a/packages/dao-testing/src/helpers.rs +++ b/packages/dao-testing/src/helpers.rs @@ -7,7 +7,7 @@ use dao_voting::threshold::ActiveThreshold; use dao_voting_cw4::msg::GroupContract; use crate::contracts::{ - cw20_balances_voting_contract, cw20_base_contract, cw20_stake_contract, + cw20_balances_voting_contract, cw20_hooks_contract, cw20_stake_contract, cw20_staked_balances_voting_contract, cw4_group_contract, dao_dao_contract, dao_voting_cw4_contract, }; @@ -20,7 +20,7 @@ pub fn instantiate_with_cw20_balances_governance( governance_instantiate: Binary, initial_balances: Option>, ) -> Addr { - let cw20_id = app.store_code(cw20_base_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let core_id = app.store_code(dao_dao_contract()); let votemod_id = app.store_code(cw20_balances_voting_contract()); @@ -123,7 +123,7 @@ pub fn instantiate_with_staked_balances_governance( .collect() }; - let cw20_id = app.store_code(cw20_base_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let cw20_stake_id = app.store_code(cw20_stake_contract()); let staked_balances_voting_id = app.store_code(cw20_staked_balances_voting_contract()); let core_contract_id = app.store_code(dao_dao_contract()); @@ -231,7 +231,7 @@ pub fn instantiate_with_staking_active_threshold( initial_balances: Option>, active_threshold: Option, ) -> Addr { - let cw20_id = app.store_code(cw20_base_contract()); + let cw20_id = app.store_code(cw20_hooks_contract()); let cw20_staking_id = app.store_code(cw20_stake_contract()); let governance_id = app.store_code(dao_dao_contract()); let votemod_id = app.store_code(cw20_staked_balances_voting_contract());