From 87485be2fbe7e3828a6eae1930ac5bf8858441c6 Mon Sep 17 00:00:00 2001 From: bekauz Date: Wed, 22 May 2024 18:39:06 +0200 Subject: [PATCH 1/5] bump cw-std to 1.5.4 (#824) --- Cargo.lock | 30 +++++++++++++++--------------- Cargo.toml | 4 ++-- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6742e170e..7ab222337 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -257,9 +257,9 @@ dependencies = [ [[package]] name = "bnum" -version = "0.8.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab9008b6bb9fc80b5277f2fe481c09e828743d9151203e804583eb4c9e15b31d" +checksum = "56953345e39537a3e18bdaeba4cb0c58a78c1f61f361dc0fa7c5c7340ae87c5f" [[package]] name = "bootstrap-env" @@ -510,32 +510,32 @@ dependencies = [ [[package]] name = "cosmwasm-crypto" -version = "1.5.0" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bb3c77c3b7ce472056968c745eb501c440fbc07be5004eba02782c35bfbbe3" +checksum = "e6b4c3f9c4616d6413d4b5fc4c270a4cc32a374b9be08671e80e1a019f805d8f" dependencies = [ "digest 0.10.7", "ecdsa 0.16.9", "ed25519-zebra", - "k256 0.13.2", + "k256 0.13.1", "rand_core 0.6.4", "thiserror", ] [[package]] name = "cosmwasm-derive" -version = "1.5.0" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fea73e9162e6efde00018d55ed0061e93a108b5d6ec4548b4f8ce3c706249687" +checksum = "c586ced10c3b00e809ee664a895025a024f60d65d34fe4c09daed4a4db68a3f3" dependencies = [ "syn 1.0.109", ] [[package]] name = "cosmwasm-schema" -version = "1.5.0" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0df41ea55f2946b6b43579659eec048cc2f66e8c8e2e3652fc5e5e476f673856" +checksum = "8467874827d384c131955ff6f4d47d02e72a956a08eb3c0ff24f8c903a5517b4" dependencies = [ "cosmwasm-schema-derive", "schemars", @@ -546,9 +546,9 @@ dependencies = [ [[package]] name = "cosmwasm-schema-derive" -version = "1.5.0" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43609e92ce1b9368aa951b334dd354a2d0dd4d484931a5f83ae10e12a26c8ba9" +checksum = "f6db85d98ac80922aef465e564d5b21fa9cfac5058cb62df7f116c3682337393" dependencies = [ "proc-macro2", "quote", @@ -557,9 +557,9 @@ dependencies = [ [[package]] name = "cosmwasm-std" -version = "1.5.0" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04d6864742e3a7662d024b51a94ea81c9af21db6faea2f9a6d2232bb97c6e53e" +checksum = "712fe58f39d55c812f7b2c84e097cdede3a39d520f89b6dc3153837e31741927" dependencies = [ "base64 0.21.5", "bech32", @@ -2929,9 +2929,9 @@ dependencies = [ [[package]] name = "k256" -version = "0.13.2" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f01b677d82ef7a676aa37e099defd83a28e15687112cafdd112d60236b6115b" +checksum = "cadb76004ed8e97623117f3df85b17aaa6626ab0b0831e6573f104df16cd1bcc" dependencies = [ "cfg-if", "ecdsa 0.16.9", diff --git a/Cargo.toml b/Cargo.toml index cf72e327a..95915acee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,8 +38,8 @@ assert_matches = "1.5" cosm-orc = { version = "4.0" } cosm-tome = "0.2" cosmos-sdk-proto = "0.19" -cosmwasm-schema = { version = "1.2" } -cosmwasm-std = { version = "1.5.0", features = ["ibc3"] } +cosmwasm-schema = { version = "1.5.4" } +cosmwasm-std = { version = "1.5.4", features = ["ibc3"] } cw-controllers = "1.1" cw-multi-test = "0.18" cw-storage-plus = { version = "1.1" } From bb8224ef1285266f4c8f11448e361f66c6a7e879 Mon Sep 17 00:00:00 2001 From: noah Date: Thu, 6 Jun 2024 00:32:12 -0400 Subject: [PATCH 2/5] moved .cargo/config to .cargo/config.toml (#837) --- .cargo/{config => config.toml} | 0 ci/bootstrap-env/src/main.rs | 3 ++- ci/integration-tests/.cargo/{config => config.toml} | 0 ci/integration-tests/src/helpers/chain.rs | 3 ++- contracts/dao-dao-core/.cargo/config | 4 ---- contracts/external/cw-admin-factory/.cargo/config | 4 ---- contracts/external/cw-fund-distributor/.cargo/config | 5 ----- contracts/external/cw-payroll-factory/.cargo/config | 4 ---- contracts/external/cw-token-swap/.cargo/config | 4 ---- contracts/external/cw-tokenfactory-issuer/.cargo/config | 4 ---- contracts/external/cw-vesting/.cargo/config | 6 ------ contracts/external/cw721-roles/.cargo/config | 5 ----- contracts/external/cw721-roles/src/contract.rs | 4 ++-- contracts/external/dao-migrator/.cargo/config | 4 ---- .../dao-pre-propose-approval-single/.cargo/config | 4 ---- .../pre-propose/dao-pre-propose-approver/.cargo/config | 4 ---- .../pre-propose/dao-pre-propose-multiple/.cargo/config | 4 ---- contracts/pre-propose/dao-pre-propose-single/.cargo/config | 4 ---- contracts/proposal/dao-proposal-condorcet/.cargo/config | 4 ---- contracts/proposal/dao-proposal-multiple/.cargo/config | 4 ---- contracts/proposal/dao-proposal-single/.cargo/config | 4 ---- contracts/staking/cw20-stake-external-rewards/.cargo/config | 4 ---- .../staking/cw20-stake-reward-distributor/.cargo/config | 4 ---- contracts/staking/cw20-stake/.cargo/config | 4 ---- contracts/test/dao-proposal-hook-counter/.cargo/config | 4 ---- contracts/test/dao-proposal-sudo/.cargo/config | 4 ---- contracts/test/dao-test-custom-factory/.cargo/config | 4 ---- contracts/test/dao-voting-cw20-balance/.cargo/config | 4 ---- contracts/voting/dao-voting-cw20-staked/.cargo/config | 4 ---- contracts/voting/dao-voting-cw4/.cargo/config | 4 ---- contracts/voting/dao-voting-cw721-roles/.cargo/config | 4 ---- contracts/voting/dao-voting-cw721-staked/.cargo/config | 4 ---- contracts/voting/dao-voting-token-staked/.cargo/config | 4 ---- packages/dao-dao-macros/.cargo/config | 2 -- packages/dao-interface/.cargo/config | 2 -- packages/dao-pre-propose-base/.cargo/config | 4 ---- 36 files changed, 6 insertions(+), 128 deletions(-) rename .cargo/{config => config.toml} (100%) rename ci/integration-tests/.cargo/{config => config.toml} (100%) delete mode 100644 contracts/dao-dao-core/.cargo/config delete mode 100644 contracts/external/cw-admin-factory/.cargo/config delete mode 100644 contracts/external/cw-fund-distributor/.cargo/config delete mode 100644 contracts/external/cw-payroll-factory/.cargo/config delete mode 100644 contracts/external/cw-token-swap/.cargo/config delete mode 100644 contracts/external/cw-tokenfactory-issuer/.cargo/config delete mode 100644 contracts/external/cw-vesting/.cargo/config delete mode 100644 contracts/external/cw721-roles/.cargo/config delete mode 100644 contracts/external/dao-migrator/.cargo/config delete mode 100644 contracts/pre-propose/dao-pre-propose-approval-single/.cargo/config delete mode 100644 contracts/pre-propose/dao-pre-propose-approver/.cargo/config delete mode 100644 contracts/pre-propose/dao-pre-propose-multiple/.cargo/config delete mode 100644 contracts/pre-propose/dao-pre-propose-single/.cargo/config delete mode 100644 contracts/proposal/dao-proposal-condorcet/.cargo/config delete mode 100644 contracts/proposal/dao-proposal-multiple/.cargo/config delete mode 100644 contracts/proposal/dao-proposal-single/.cargo/config delete mode 100644 contracts/staking/cw20-stake-external-rewards/.cargo/config delete mode 100644 contracts/staking/cw20-stake-reward-distributor/.cargo/config delete mode 100644 contracts/staking/cw20-stake/.cargo/config delete mode 100644 contracts/test/dao-proposal-hook-counter/.cargo/config delete mode 100644 contracts/test/dao-proposal-sudo/.cargo/config delete mode 100644 contracts/test/dao-test-custom-factory/.cargo/config delete mode 100644 contracts/test/dao-voting-cw20-balance/.cargo/config delete mode 100644 contracts/voting/dao-voting-cw20-staked/.cargo/config delete mode 100644 contracts/voting/dao-voting-cw4/.cargo/config delete mode 100644 contracts/voting/dao-voting-cw721-roles/.cargo/config delete mode 100644 contracts/voting/dao-voting-cw721-staked/.cargo/config delete mode 100644 contracts/voting/dao-voting-token-staked/.cargo/config delete mode 100644 packages/dao-dao-macros/.cargo/config delete mode 100644 packages/dao-interface/.cargo/config delete mode 100644 packages/dao-pre-propose-base/.cargo/config diff --git a/.cargo/config b/.cargo/config.toml similarity index 100% rename from .cargo/config rename to .cargo/config.toml diff --git a/ci/bootstrap-env/src/main.rs b/ci/bootstrap-env/src/main.rs index c1c321c91..83d6b82c9 100644 --- a/ci/bootstrap-env/src/main.rs +++ b/ci/bootstrap-env/src/main.rs @@ -183,7 +183,8 @@ fn main() -> Result<()> { ); // Persist contract code_ids in local.yaml so we can use SKIP_CONTRACT_STORE locally to avoid having to re-store them again - cfg.contract_deploy_info = orc.contract_map.deploy_info().clone(); + cfg.contract_deploy_info + .clone_from(orc.contract_map.deploy_info()); fs::write( "ci/configs/cosm-orc/local.yaml", serde_yaml::to_string(&cfg)?, diff --git a/ci/integration-tests/.cargo/config b/ci/integration-tests/.cargo/config.toml similarity index 100% rename from ci/integration-tests/.cargo/config rename to ci/integration-tests/.cargo/config.toml diff --git a/ci/integration-tests/src/helpers/chain.rs b/ci/integration-tests/src/helpers/chain.rs index 20b3d27d4..fd7445cde 100644 --- a/ci/integration-tests/src/helpers/chain.rs +++ b/ci/integration-tests/src/helpers/chain.rs @@ -99,7 +99,8 @@ fn global_setup() -> Cfg { .unwrap(); save_gas_report(&orc, &gas_report_dir); // persist stored code_ids in CONFIG, so we can reuse for all tests - cfg.contract_deploy_info = orc.contract_map.deploy_info().clone(); + cfg.contract_deploy_info + .clone_from(orc.contract_map.deploy_info()); } Cfg { diff --git a/contracts/dao-dao-core/.cargo/config b/contracts/dao-dao-core/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/dao-dao-core/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/external/cw-admin-factory/.cargo/config b/contracts/external/cw-admin-factory/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/external/cw-admin-factory/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/external/cw-fund-distributor/.cargo/config b/contracts/external/cw-fund-distributor/.cargo/config deleted file mode 100644 index 5ea56d6f3..000000000 --- a/contracts/external/cw-fund-distributor/.cargo/config +++ /dev/null @@ -1,5 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" - diff --git a/contracts/external/cw-payroll-factory/.cargo/config b/contracts/external/cw-payroll-factory/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/external/cw-payroll-factory/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/external/cw-token-swap/.cargo/config b/contracts/external/cw-token-swap/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/external/cw-token-swap/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/external/cw-tokenfactory-issuer/.cargo/config b/contracts/external/cw-tokenfactory-issuer/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/external/cw-tokenfactory-issuer/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/external/cw-vesting/.cargo/config b/contracts/external/cw-vesting/.cargo/config deleted file mode 100644 index 8d4bc738b..000000000 --- a/contracts/external/cw-vesting/.cargo/config +++ /dev/null @@ -1,6 +0,0 @@ -[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/cw721-roles/.cargo/config b/contracts/external/cw721-roles/.cargo/config deleted file mode 100644 index 7d1a066c8..000000000 --- a/contracts/external/cw721-roles/.cargo/config +++ /dev/null @@ -1,5 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -wasm-debug = "build --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/external/cw721-roles/src/contract.rs b/contracts/external/cw721-roles/src/contract.rs index 833007e46..ea31936a1 100644 --- a/contracts/external/cw721-roles/src/contract.rs +++ b/contracts/external/cw721-roles/src/contract.rs @@ -319,7 +319,7 @@ pub fn execute_update_token_role( let mut token = contract.tokens.load(deps.storage, &token_id)?; // Update role with new value - token.extension.role = role.clone(); + token.extension.role.clone_from(&role); contract.tokens.save(deps.storage, &token_id, &token)?; Ok(Response::default() @@ -341,7 +341,7 @@ pub fn execute_update_token_uri( let mut token = contract.tokens.load(deps.storage, &token_id)?; // Set new token URI - token.token_uri = token_uri.clone(); + token.token_uri.clone_from(&token_uri); contract.tokens.save(deps.storage, &token_id, &token)?; Ok(Response::new() diff --git a/contracts/external/dao-migrator/.cargo/config b/contracts/external/dao-migrator/.cargo/config deleted file mode 100644 index ab407a024..000000000 --- a/contracts/external/dao-migrator/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --lib --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/.cargo/config b/contracts/pre-propose/dao-pre-propose-approval-single/.cargo/config deleted file mode 100644 index ab407a024..000000000 --- a/contracts/pre-propose/dao-pre-propose-approval-single/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --lib --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/pre-propose/dao-pre-propose-approver/.cargo/config b/contracts/pre-propose/dao-pre-propose-approver/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/pre-propose/dao-pre-propose-approver/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/pre-propose/dao-pre-propose-multiple/.cargo/config b/contracts/pre-propose/dao-pre-propose-multiple/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/pre-propose/dao-pre-propose-multiple/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/pre-propose/dao-pre-propose-single/.cargo/config b/contracts/pre-propose/dao-pre-propose-single/.cargo/config deleted file mode 100644 index ab407a024..000000000 --- a/contracts/pre-propose/dao-pre-propose-single/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --lib --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/proposal/dao-proposal-condorcet/.cargo/config b/contracts/proposal/dao-proposal-condorcet/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/proposal/dao-proposal-condorcet/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/proposal/dao-proposal-multiple/.cargo/config b/contracts/proposal/dao-proposal-multiple/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/proposal/dao-proposal-multiple/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/proposal/dao-proposal-single/.cargo/config b/contracts/proposal/dao-proposal-single/.cargo/config deleted file mode 100644 index ab407a024..000000000 --- a/contracts/proposal/dao-proposal-single/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --lib --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/staking/cw20-stake-external-rewards/.cargo/config b/contracts/staking/cw20-stake-external-rewards/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/staking/cw20-stake-external-rewards/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/staking/cw20-stake-reward-distributor/.cargo/config b/contracts/staking/cw20-stake-reward-distributor/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/staking/cw20-stake-reward-distributor/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/staking/cw20-stake/.cargo/config b/contracts/staking/cw20-stake/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/staking/cw20-stake/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/test/dao-proposal-hook-counter/.cargo/config b/contracts/test/dao-proposal-hook-counter/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/test/dao-proposal-hook-counter/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/test/dao-proposal-sudo/.cargo/config b/contracts/test/dao-proposal-sudo/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/test/dao-proposal-sudo/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/test/dao-test-custom-factory/.cargo/config b/contracts/test/dao-test-custom-factory/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/test/dao-test-custom-factory/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/test/dao-voting-cw20-balance/.cargo/config b/contracts/test/dao-voting-cw20-balance/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/test/dao-voting-cw20-balance/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/voting/dao-voting-cw20-staked/.cargo/config b/contracts/voting/dao-voting-cw20-staked/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/voting/dao-voting-cw20-staked/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/voting/dao-voting-cw4/.cargo/config b/contracts/voting/dao-voting-cw4/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/voting/dao-voting-cw4/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/voting/dao-voting-cw721-roles/.cargo/config b/contracts/voting/dao-voting-cw721-roles/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/voting/dao-voting-cw721-roles/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/voting/dao-voting-cw721-staked/.cargo/config b/contracts/voting/dao-voting-cw721-staked/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/voting/dao-voting-cw721-staked/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/contracts/voting/dao-voting-token-staked/.cargo/config b/contracts/voting/dao-voting-token-staked/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/contracts/voting/dao-voting-token-staked/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" diff --git a/packages/dao-dao-macros/.cargo/config b/packages/dao-dao-macros/.cargo/config deleted file mode 100644 index e44e70f31..000000000 --- a/packages/dao-dao-macros/.cargo/config +++ /dev/null @@ -1,2 +0,0 @@ -[alias] -schema = "run --example schema" diff --git a/packages/dao-interface/.cargo/config b/packages/dao-interface/.cargo/config deleted file mode 100644 index e44e70f31..000000000 --- a/packages/dao-interface/.cargo/config +++ /dev/null @@ -1,2 +0,0 @@ -[alias] -schema = "run --example schema" diff --git a/packages/dao-pre-propose-base/.cargo/config b/packages/dao-pre-propose-base/.cargo/config deleted file mode 100644 index 336b618a1..000000000 --- a/packages/dao-pre-propose-base/.cargo/config +++ /dev/null @@ -1,4 +0,0 @@ -[alias] -wasm = "build --release --target wasm32-unknown-unknown" -unit-test = "test --lib" -schema = "run --example schema" From 8be18d1e8fde2507527f95247e26a0ed7c049aa8 Mon Sep 17 00:00:00 2001 From: bekauz Date: Mon, 17 Jun 2024 18:05:24 +0200 Subject: [PATCH 3/5] staking rewards distribution followup (#835) Co-authored-by: Noah Saso Co-authored-by: Jake Hartnell --- .cargo/config.toml | 1 + Cargo.lock | 32 +- Cargo.toml | 3 + .../cw-fund-distributor/Cargo.toml | 2 +- .../cw-fund-distributor/README.md | 0 .../cw-fund-distributor/examples/schema.rs | 0 .../schema/cw-fund-distributor.json | 2 +- .../cw-fund-distributor/src/contract.rs | 0 .../cw-fund-distributor/src/error.rs | 0 .../cw-fund-distributor/src/lib.rs | 0 .../cw-fund-distributor/src/msg.rs | 0 .../cw-fund-distributor/src/state.rs | 0 .../src/testing/adversarial_tests.rs | 0 .../cw-fund-distributor/src/testing/mod.rs | 0 .../cw-fund-distributor/src/testing/tests.rs | 0 .../dao-rewards-distributor/Cargo.toml | 44 + .../dao-rewards-distributor/README.md | 31 + .../examples/schema.rs | 11 + .../schema/dao-rewards-distributor.json | 1360 +++++++++++++++++ .../dao-rewards-distributor/src/contract.rs | 648 ++++++++ .../dao-rewards-distributor/src/error.rs | 41 + .../dao-rewards-distributor/src/hooks.rs | 113 ++ .../dao-rewards-distributor/src/lib.rs | 12 + .../dao-rewards-distributor/src/msg.rs | 141 ++ .../dao-rewards-distributor/src/state.rs | 163 ++ .../src/testing/mod.rs | 298 ++++ .../src/testing/suite.rs | 720 +++++++++ .../src/testing/tests.rs | 1143 ++++++++++++++ .../dao-proposal-multiple/src/error.rs | 2 - .../src/testing/do_votes.rs | 8 +- .../proposal/dao-proposal-single/src/error.rs | 2 - packages/dao-testing/src/tests.rs | 12 +- scripts/schema.sh | 10 + 33 files changed, 4782 insertions(+), 17 deletions(-) rename contracts/{external => distribution}/cw-fund-distributor/Cargo.toml (97%) rename contracts/{external => distribution}/cw-fund-distributor/README.md (100%) rename contracts/{external => distribution}/cw-fund-distributor/examples/schema.rs (100%) rename contracts/{external => distribution}/cw-fund-distributor/schema/cw-fund-distributor.json (99%) rename contracts/{external => distribution}/cw-fund-distributor/src/contract.rs (100%) rename contracts/{external => distribution}/cw-fund-distributor/src/error.rs (100%) rename contracts/{external => distribution}/cw-fund-distributor/src/lib.rs (100%) rename contracts/{external => distribution}/cw-fund-distributor/src/msg.rs (100%) rename contracts/{external => distribution}/cw-fund-distributor/src/state.rs (100%) rename contracts/{external => distribution}/cw-fund-distributor/src/testing/adversarial_tests.rs (100%) rename contracts/{external => distribution}/cw-fund-distributor/src/testing/mod.rs (100%) rename contracts/{external => distribution}/cw-fund-distributor/src/testing/tests.rs (100%) create mode 100644 contracts/distribution/dao-rewards-distributor/Cargo.toml create mode 100644 contracts/distribution/dao-rewards-distributor/README.md create mode 100644 contracts/distribution/dao-rewards-distributor/examples/schema.rs create mode 100644 contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json create mode 100644 contracts/distribution/dao-rewards-distributor/src/contract.rs create mode 100644 contracts/distribution/dao-rewards-distributor/src/error.rs create mode 100644 contracts/distribution/dao-rewards-distributor/src/hooks.rs create mode 100644 contracts/distribution/dao-rewards-distributor/src/lib.rs create mode 100644 contracts/distribution/dao-rewards-distributor/src/msg.rs create mode 100644 contracts/distribution/dao-rewards-distributor/src/state.rs create mode 100644 contracts/distribution/dao-rewards-distributor/src/testing/mod.rs create mode 100644 contracts/distribution/dao-rewards-distributor/src/testing/suite.rs create mode 100644 contracts/distribution/dao-rewards-distributor/src/testing/tests.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index e5fdda2c5..9fe067ec1 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -5,6 +5,7 @@ all-test = "test --workspace" unit-test = "test --lib" integration-test = "test --package integration-tests -- --ignored --test-threads 1 -Z unstable-options --report-time" test-tube = "test --features test-tube" +schema = "run --example schema" [env] RUSTFLAGS = "-C link-arg=-s" diff --git a/Cargo.lock b/Cargo.lock index 7ab222337..486dc1046 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,7 +801,7 @@ dependencies = [ [[package]] name = "cw-fund-distributor" -version = "0.1.0" +version = "2.4.2" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1931,6 +1931,36 @@ dependencies = [ "thiserror", ] +[[package]] +name = "dao-rewards-distributor" +version = "2.4.2" +dependencies = [ + "anyhow", + "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", + "cw20-stake 2.4.2", + "cw4 1.1.2", + "cw4-group 1.1.2", + "cw721-base 0.18.0", + "dao-hooks", + "dao-interface", + "dao-testing", + "dao-voting 2.4.2", + "dao-voting-cw20-staked", + "dao-voting-cw4", + "dao-voting-cw721-staked", + "dao-voting-token-staked", + "thiserror", +] + [[package]] name = "dao-test-custom-factory" version = "2.4.2" diff --git a/Cargo.toml b/Cargo.toml index 95915acee..2f2d2ebe4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ exclude = ["ci/configs/", "wasmvm/libwasmvm"] members = [ "contracts/dao-dao-core", + "contracts/distribution/*", "contracts/external/*", "contracts/proposal/*", "contracts/pre-propose/*", @@ -82,6 +83,7 @@ cw-ownable = "0.5" cw-admin-factory = { path = "./contracts/external/cw-admin-factory", version = "2.4.2" } cw-denom = { path = "./packages/cw-denom", version = "2.4.2" } +cw-fund-distributor = { path = "./contracts/distribution/cw-fund-distributor", version = "2.4.2" } cw-hooks = { path = "./packages/cw-hooks", version = "2.4.2" } cw-paginate-storage = { path = "./packages/cw-paginate-storage", version = "2.4.2" } cw-payroll-factory = { path = "./contracts/external/cw-payroll-factory", version = "2.4.2" } @@ -108,6 +110,7 @@ dao-proposal-hook-counter = { path = "./contracts/test/dao-proposal-hook-counter dao-proposal-multiple = { path = "./contracts/proposal/dao-proposal-multiple", version = "2.4.2" } dao-proposal-single = { path = "./contracts/proposal/dao-proposal-single", version = "2.4.2" } dao-proposal-sudo = { path = "./contracts/test/dao-proposal-sudo", version = "2.4.2" } +dao-rewards-distributor = { path = "./contracts/distribution/dao-rewards-distributor", version = "2.4.2" } dao-test-custom-factory = { path = "./contracts/test/dao-test-custom-factory", version = "2.4.2" } dao-testing = { path = "./packages/dao-testing", version = "2.4.2" } dao-voting = { path = "./packages/dao-voting", version = "2.4.2" } diff --git a/contracts/external/cw-fund-distributor/Cargo.toml b/contracts/distribution/cw-fund-distributor/Cargo.toml similarity index 97% rename from contracts/external/cw-fund-distributor/Cargo.toml rename to contracts/distribution/cw-fund-distributor/Cargo.toml index 558478ae3..f73aba4df 100644 --- a/contracts/external/cw-fund-distributor/Cargo.toml +++ b/contracts/distribution/cw-fund-distributor/Cargo.toml @@ -5,7 +5,7 @@ description = "A CosmWasm contract for distributing funds to DAO members based o edition = { workspace = true } license = { workspace = true } repository = { workspace = true } -version = "0.1.0" +version = { workspace = true } [lib] crate-type = ["cdylib", "rlib"] diff --git a/contracts/external/cw-fund-distributor/README.md b/contracts/distribution/cw-fund-distributor/README.md similarity index 100% rename from contracts/external/cw-fund-distributor/README.md rename to contracts/distribution/cw-fund-distributor/README.md diff --git a/contracts/external/cw-fund-distributor/examples/schema.rs b/contracts/distribution/cw-fund-distributor/examples/schema.rs similarity index 100% rename from contracts/external/cw-fund-distributor/examples/schema.rs rename to contracts/distribution/cw-fund-distributor/examples/schema.rs diff --git a/contracts/external/cw-fund-distributor/schema/cw-fund-distributor.json b/contracts/distribution/cw-fund-distributor/schema/cw-fund-distributor.json similarity index 99% rename from contracts/external/cw-fund-distributor/schema/cw-fund-distributor.json rename to contracts/distribution/cw-fund-distributor/schema/cw-fund-distributor.json index 19a0541ca..02b368e07 100644 --- a/contracts/external/cw-fund-distributor/schema/cw-fund-distributor.json +++ b/contracts/distribution/cw-fund-distributor/schema/cw-fund-distributor.json @@ -1,6 +1,6 @@ { "contract_name": "cw-fund-distributor", - "contract_version": "0.1.0", + "contract_version": "2.4.2", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/external/cw-fund-distributor/src/contract.rs b/contracts/distribution/cw-fund-distributor/src/contract.rs similarity index 100% rename from contracts/external/cw-fund-distributor/src/contract.rs rename to contracts/distribution/cw-fund-distributor/src/contract.rs diff --git a/contracts/external/cw-fund-distributor/src/error.rs b/contracts/distribution/cw-fund-distributor/src/error.rs similarity index 100% rename from contracts/external/cw-fund-distributor/src/error.rs rename to contracts/distribution/cw-fund-distributor/src/error.rs diff --git a/contracts/external/cw-fund-distributor/src/lib.rs b/contracts/distribution/cw-fund-distributor/src/lib.rs similarity index 100% rename from contracts/external/cw-fund-distributor/src/lib.rs rename to contracts/distribution/cw-fund-distributor/src/lib.rs diff --git a/contracts/external/cw-fund-distributor/src/msg.rs b/contracts/distribution/cw-fund-distributor/src/msg.rs similarity index 100% rename from contracts/external/cw-fund-distributor/src/msg.rs rename to contracts/distribution/cw-fund-distributor/src/msg.rs diff --git a/contracts/external/cw-fund-distributor/src/state.rs b/contracts/distribution/cw-fund-distributor/src/state.rs similarity index 100% rename from contracts/external/cw-fund-distributor/src/state.rs rename to contracts/distribution/cw-fund-distributor/src/state.rs diff --git a/contracts/external/cw-fund-distributor/src/testing/adversarial_tests.rs b/contracts/distribution/cw-fund-distributor/src/testing/adversarial_tests.rs similarity index 100% rename from contracts/external/cw-fund-distributor/src/testing/adversarial_tests.rs rename to contracts/distribution/cw-fund-distributor/src/testing/adversarial_tests.rs diff --git a/contracts/external/cw-fund-distributor/src/testing/mod.rs b/contracts/distribution/cw-fund-distributor/src/testing/mod.rs similarity index 100% rename from contracts/external/cw-fund-distributor/src/testing/mod.rs rename to contracts/distribution/cw-fund-distributor/src/testing/mod.rs diff --git a/contracts/external/cw-fund-distributor/src/testing/tests.rs b/contracts/distribution/cw-fund-distributor/src/testing/tests.rs similarity index 100% rename from contracts/external/cw-fund-distributor/src/testing/tests.rs rename to contracts/distribution/cw-fund-distributor/src/testing/tests.rs diff --git a/contracts/distribution/dao-rewards-distributor/Cargo.toml b/contracts/distribution/dao-rewards-distributor/Cargo.toml new file mode 100644 index 000000000..0df6ef1d5 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "dao-rewards-distributor" +authors = ["Ben2x4 ", "ekez ", "Jake Hartnell ", "bekauz "] +description = "Distributes rewards based on DAO membership." +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +version = { workspace = true } + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw2 = { workspace = true } +cw4 = { workspace = true } +cw20 = { workspace = true } +cw20-base = { workspace = true, features = ["library"] } +cw-controllers = { workspace = true } +cw-ownable = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +dao-hooks = { workspace = true } +dao-interface = { workspace = true } +dao-voting = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } +anyhow = { workspace = true } +cw20-stake = { workspace = true, features = ["library"] } +cw4-group = { workspace = true, features = ["library"] } +cw721-base = { workspace = true, features = ["library"] } +dao-voting-cw20-staked = { workspace = true, features = ["library"] } +dao-voting-cw4 = { workspace = true, features = ["library"] } +dao-voting-token-staked = { workspace = true, features = ["library"] } +dao-voting-cw721-staked = { workspace = true, features = ["library"] } +dao-testing = { workspace = true } diff --git a/contracts/distribution/dao-rewards-distributor/README.md b/contracts/distribution/dao-rewards-distributor/README.md new file mode 100644 index 000000000..a2e355d14 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/README.md @@ -0,0 +1,31 @@ +# DAO Rewards Distributor + +[![dao-rewards-distributor on crates.io](https://img.shields.io/crates/v/dao-rewards-distributor.svg?logo=rust)](https://crates.io/crates/dao-rewards-distributor) +[![docs.rs](https://img.shields.io/docsrs/dao-rewards-distributor?logo=docsdotrs)](https://docs.rs/dao-rewards-distributor/latest/cw20_stake_external_rewards/) + +The `dao-rewards-distributor` works in conjuction with DAO voting modules to provide rewards over time for DAO members. The contract supports both cw20 and native Cosmos SDK tokens. The following voting power modules are supported: +- `dao-voting-cw4`: for membership or group based DAOs +- `dao-voting-cw20-staked`: for cw20 token based DAOs. +- `dao-voting-cw721-staked`: for NFT based DAOs. +- `dao-voting-token-staked`: for native and Token Factory token based DAOs. + +NOTE: this contract is NOT AUDITED and is _experimental_. USE AT YOUR OWN RISK. + +## Instantiation and Setup + +The contract is instantiated with a number of parameters: +- `owner`: The owner of the contract. Is able to fund the contract and update the reward duration. +- `vp_contract`: A DAO DAO voting power module contract address, used to determine membership in the DAO over time. +- `hook_caller`: An optional contract that is allowed to call voting power change hooks. Often, as in `dao-voting-token-staked` and `dao-voting-cw721-staked` the vp_contract calls hooks for power change events, but sometimes they are separate. For example, the `cw4-group` contract is separate from the `dao-voting-cw4` contract and since the `cw4-group` contract fires the membership change events, it's address would be used as the `hook_caller`. +- `reward_denom`: the denomination of the reward token, can be either a cw20 or native token. +- `reward_duration`: the time period over which rewards are to be paid out in blocks. + +After instantiating the contract it is VITAL to setup the required hooks for it to work. This is because to pay out rewards accurately, this contract needs to know about staking or voting power changes in the DAO. + +This can be achieved using the `add_hook` method on contracts that support voting power changes, which are: +- `cw4-group` +- `dao-voting-cw721-staked` +- `dao-voting-token-staked` +- `cw20-stake` + +Finally, the contract needs to be funded with a token matching the denom specified in the `reward_denom` field during instantiation. This can be achieved by calling the `fund` method on the `dao-rewards-distributor` smart contract, and sending along the appropriate funds. diff --git a/contracts/distribution/dao-rewards-distributor/examples/schema.rs b/contracts/distribution/dao-rewards-distributor/examples/schema.rs new file mode 100644 index 000000000..fa0fd072e --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/examples/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use dao_rewards_distributor::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json new file mode 100644 index 000000000..e7d00a62b --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -0,0 +1,1360 @@ +{ + "contract_name": "dao-rewards-distributor", + "contract_version": "2.4.2", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "properties": { + "owner": { + "description": "The owner of the contract. Is able to fund the contract and update the reward duration.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Called when a member is added or removed to a cw4-groups or cw721-roles contract.", + "type": "object", + "required": [ + "member_changed_hook" + ], + "properties": { + "member_changed_hook": { + "$ref": "#/definitions/MemberChangedHookMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Called when NFTs are staked or unstaked.", + "type": "object", + "required": [ + "nft_stake_change_hook" + ], + "properties": { + "nft_stake_change_hook": { + "$ref": "#/definitions/NftStakeChangedHookMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Called when tokens are staked or unstaked.", + "type": "object", + "required": [ + "stake_change_hook" + ], + "properties": { + "stake_change_hook": { + "$ref": "#/definitions/StakeChangedHookMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Claims rewards for the sender.", + "type": "object", + "required": [ + "claim" + ], + "properties": { + "claim": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Used to fund this contract with cw20 tokens.", + "type": "object", + "required": [ + "receive" + ], + "properties": { + "receive": { + "$ref": "#/definitions/Cw20ReceiveMsg" + } + }, + "additionalProperties": false + }, + { + "description": "Used to fund this contract with native tokens.", + "type": "object", + "required": [ + "fund" + ], + "properties": { + "fund": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "shuts down the rewards distributor. withdraws all future staking rewards back to the treasury. members can claim whatever they earned until this point.", + "type": "object", + "required": [ + "shutdown" + ], + "properties": { + "shutdown": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "registers a new reward denom", + "type": "object", + "required": [ + "register_reward_denom" + ], + "properties": { + "register_reward_denom": { + "type": "object", + "required": [ + "denom", + "emission_rate", + "hook_caller", + "vp_contract" + ], + "properties": { + "denom": { + "$ref": "#/definitions/UncheckedDenom" + }, + "emission_rate": { + "$ref": "#/definitions/RewardEmissionRate" + }, + "hook_caller": { + "type": "string" + }, + "vp_contract": { + "type": "string" + }, + "withdraw_destination": { + "type": [ + "string", + "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" + ] + } + ] + }, + "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" + }, + "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" + }, + "Cw20ReceiveMsg": { + "description": "Cw20ReceiveMsg should be de/serialized under `Receive()` variant in a ExecuteMsg", + "type": "object", + "required": [ + "amount", + "msg", + "sender" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "msg": { + "$ref": "#/definitions/Binary" + }, + "sender": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Duration": { + "description": "Duration is a delta of time. You can add it to a BlockInfo or Expiration to move that further in the future. Note that an height-based Duration and a time-based Expiration cannot be combined", + "oneOf": [ + { + "type": "object", + "required": [ + "height" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "Time in seconds", + "type": "object", + "required": [ + "time" + ], + "properties": { + "time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "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 + } + ] + }, + "MemberChangedHookMsg": { + "description": "MemberChangedHookMsg should be de/serialized under `MemberChangedHook()` variant in a ExecuteMsg. This contains a list of all diffs on the given transaction.", + "type": "object", + "required": [ + "diffs" + ], + "properties": { + "diffs": { + "type": "array", + "items": { + "$ref": "#/definitions/MemberDiff" + } + } + }, + "additionalProperties": false + }, + "MemberDiff": { + "description": "MemberDiff shows the old and new states for a given cw4 member They cannot both be None. old = None, new = Some -> Insert old = Some, new = Some -> Update old = Some, new = None -> Delete", + "type": "object", + "required": [ + "key" + ], + "properties": { + "key": { + "type": "string" + }, + "new": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + }, + "old": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "NftStakeChangedHookMsg": { + "description": "An enum representing NFT staking hooks.", + "oneOf": [ + { + "type": "object", + "required": [ + "stake" + ], + "properties": { + "stake": { + "type": "object", + "required": [ + "addr", + "token_id" + ], + "properties": { + "addr": { + "$ref": "#/definitions/Addr" + }, + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "unstake" + ], + "properties": { + "unstake": { + "type": "object", + "required": [ + "addr", + "token_ids" + ], + "properties": { + "addr": { + "$ref": "#/definitions/Addr" + }, + "token_ids": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "RewardEmissionRate": { + "description": "defines how many tokens (amount) should be distributed per amount of time (duration). e.g. 5udenom per hour.", + "type": "object", + "required": [ + "amount", + "duration" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "duration": { + "$ref": "#/definitions/Duration" + } + }, + "additionalProperties": false + }, + "StakeChangedHookMsg": { + "description": "An enum representing staking hooks.", + "oneOf": [ + { + "type": "object", + "required": [ + "stake" + ], + "properties": { + "stake": { + "type": "object", + "required": [ + "addr", + "amount" + ], + "properties": { + "addr": { + "$ref": "#/definitions/Addr" + }, + "amount": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "unstake" + ], + "properties": { + "unstake": { + "type": "object", + "required": [ + "addr", + "amount" + ], + "properties": { + "addr": { + "$ref": "#/definitions/Addr" + }, + "amount": { + "$ref": "#/definitions/Uint128" + } + }, + "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" + }, + "UncheckedDenom": { + "oneOf": [ + { + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Returns contract version info", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the state of the registered reward distributions.", + "type": "object", + "required": [ + "rewards_state" + ], + "properties": { + "rewards_state": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the pending rewards for the given address.", + "type": "object", + "required": [ + "get_pending_rewards" + ], + "properties": { + "get_pending_rewards": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns information about the ownership of this contract.", + "type": "object", + "required": [ + "ownership" + ], + "properties": { + "ownership": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "denom_reward_state" + ], + "properties": { + "denom_reward_state": { + "type": "object", + "required": [ + "denom" + ], + "properties": { + "denom": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "string", + "enum": [] + }, + "sudo": null, + "responses": { + "denom_reward_state": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DenomRewardState", + "description": "the state of a denom's reward distribution", + "type": "object", + "required": [ + "denom", + "emission_rate", + "ends_at", + "funded_amount", + "hook_caller", + "last_update", + "started_at", + "total_earned_puvp", + "vp_contract", + "withdraw_destination" + ], + "properties": { + "denom": { + "description": "validated denom (native or cw20)", + "allOf": [ + { + "$ref": "#/definitions/Denom" + } + ] + }, + "emission_rate": { + "description": "reward emission rate", + "allOf": [ + { + "$ref": "#/definitions/RewardEmissionRate" + } + ] + }, + "ends_at": { + "description": "the time when all funded rewards are allocated to users and thus the distribution period ends.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "funded_amount": { + "description": "total amount of rewards funded", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "hook_caller": { + "description": "address that will update the reward split when the voting power distribution changes", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "last_update": { + "description": "time when total_earned_puvp was last updated for this denom", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "started_at": { + "description": "the time when the current reward distribution period started. period finishes iff it reaches its end.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "total_earned_puvp": { + "description": "total rewards earned per unit voting power from started_at to last_update", + "allOf": [ + { + "$ref": "#/definitions/Uint256" + } + ] + }, + "vp_contract": { + "description": "address to query the voting power", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "withdraw_destination": { + "description": "optional destination address for reward clawbacks", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + } + }, + "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" + }, + "Denom": { + "oneOf": [ + { + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "Duration": { + "description": "Duration is a delta of time. You can add it to a BlockInfo or Expiration to move that further in the future. Note that an height-based Duration and a time-based Expiration cannot be combined", + "oneOf": [ + { + "type": "object", + "required": [ + "height" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "Time in seconds", + "type": "object", + "required": [ + "time" + ], + "properties": { + "time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "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 + } + ] + }, + "RewardEmissionRate": { + "description": "defines how many tokens (amount) should be distributed per amount of time (duration). e.g. 5udenom per hour.", + "type": "object", + "required": [ + "amount", + "duration" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "duration": { + "$ref": "#/definitions/Duration" + } + }, + "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" + }, + "Uint256": { + "description": "An implementation of u256 that is using strings for JSON encoding/decoding, such that the full u256 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 out of primitive uint types or `new` to provide big endian bytes:\n\n``` # use cosmwasm_std::Uint256; let a = Uint256::from(258u128); let b = Uint256::new([ 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 1u8, 2u8, ]); assert_eq!(a, b); ```", + "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" + } + } + }, + "get_pending_rewards": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "PendingRewardsResponse", + "type": "object", + "required": [ + "address", + "pending_rewards" + ], + "properties": { + "address": { + "type": "string" + }, + "pending_rewards": { + "type": "object", + "additionalProperties": { + "$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" + } + } + }, + "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 + } + } + }, + "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" + } + } + }, + "rewards_state": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "RewardsStateResponse", + "type": "object", + "required": [ + "rewards" + ], + "properties": { + "rewards": { + "type": "array", + "items": { + "$ref": "#/definitions/DenomRewardState" + } + } + }, + "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" + }, + "Denom": { + "oneOf": [ + { + "type": "object", + "required": [ + "native" + ], + "properties": { + "native": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "cw20" + ], + "properties": { + "cw20": { + "$ref": "#/definitions/Addr" + } + }, + "additionalProperties": false + } + ] + }, + "DenomRewardState": { + "description": "the state of a denom's reward distribution", + "type": "object", + "required": [ + "denom", + "emission_rate", + "ends_at", + "funded_amount", + "hook_caller", + "last_update", + "started_at", + "total_earned_puvp", + "vp_contract", + "withdraw_destination" + ], + "properties": { + "denom": { + "description": "validated denom (native or cw20)", + "allOf": [ + { + "$ref": "#/definitions/Denom" + } + ] + }, + "emission_rate": { + "description": "reward emission rate", + "allOf": [ + { + "$ref": "#/definitions/RewardEmissionRate" + } + ] + }, + "ends_at": { + "description": "the time when all funded rewards are allocated to users and thus the distribution period ends.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "funded_amount": { + "description": "total amount of rewards funded", + "allOf": [ + { + "$ref": "#/definitions/Uint128" + } + ] + }, + "hook_caller": { + "description": "address that will update the reward split when the voting power distribution changes", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "last_update": { + "description": "time when total_earned_puvp was last updated for this denom", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "started_at": { + "description": "the time when the current reward distribution period started. period finishes iff it reaches its end.", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "total_earned_puvp": { + "description": "total rewards earned per unit voting power from started_at to last_update", + "allOf": [ + { + "$ref": "#/definitions/Uint256" + } + ] + }, + "vp_contract": { + "description": "address to query the voting power", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + }, + "withdraw_destination": { + "description": "optional destination address for reward clawbacks", + "allOf": [ + { + "$ref": "#/definitions/Addr" + } + ] + } + }, + "additionalProperties": false + }, + "Duration": { + "description": "Duration is a delta of time. You can add it to a BlockInfo or Expiration to move that further in the future. Note that an height-based Duration and a time-based Expiration cannot be combined", + "oneOf": [ + { + "type": "object", + "required": [ + "height" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "Time in seconds", + "type": "object", + "required": [ + "time" + ], + "properties": { + "time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "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 + } + ] + }, + "RewardEmissionRate": { + "description": "defines how many tokens (amount) should be distributed per amount of time (duration). e.g. 5udenom per hour.", + "type": "object", + "required": [ + "amount", + "duration" + ], + "properties": { + "amount": { + "$ref": "#/definitions/Uint128" + }, + "duration": { + "$ref": "#/definitions/Duration" + } + }, + "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" + }, + "Uint256": { + "description": "An implementation of u256 that is using strings for JSON encoding/decoding, such that the full u256 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 out of primitive uint types or `new` to provide big endian bytes:\n\n``` # use cosmwasm_std::Uint256; let a = Uint256::from(258u128); let b = Uint256::new([ 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 1u8, 2u8, ]); assert_eq!(a, b); ```", + "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" + } + } + } + } +} diff --git a/contracts/distribution/dao-rewards-distributor/src/contract.rs b/contracts/distribution/dao-rewards-distributor/src/contract.rs new file mode 100644 index 000000000..83d4d868b --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/contract.rs @@ -0,0 +1,648 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + coins, ensure, from_json, to_json_binary, Addr, BankMsg, Binary, CosmosMsg, Decimal, Deps, + DepsMut, Env, MessageInfo, Order, Response, StdError, StdResult, Uint128, Uint256, WasmMsg, +}; +use cw2::{get_contract_version, set_contract_version}; +use cw20::{Cw20ReceiveMsg, Denom, UncheckedDenom}; +use cw_utils::{one_coin, Duration, Expiration}; +use dao_interface::voting::{ + InfoResponse, Query as VotingQueryMsg, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, +}; +use std::collections::HashMap; +use std::convert::TryInto; + +use crate::hooks::{ + execute_membership_changed, execute_nft_stake_changed, execute_stake_changed, + subscribe_denom_to_hook, +}; +use crate::msg::{ + ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, ReceiveMsg, RewardEmissionRate, + RewardsStateResponse, +}; +use crate::state::{DenomRewardState, DENOM_REWARD_STATES, USER_REWARD_STATES}; +use crate::ContractError; + +const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + // Intialize the contract owner + cw_ownable::initialize_owner(deps.storage, deps.api, msg.owner.as_deref())?; + + Ok(Response::new().add_attribute("owner", msg.owner.unwrap_or_else(|| "None".to_string()))) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::StakeChangeHook(msg) => execute_stake_changed(deps, env, info, msg), + ExecuteMsg::NftStakeChangeHook(msg) => execute_nft_stake_changed(deps, env, info, msg), + ExecuteMsg::MemberChangedHook(msg) => execute_membership_changed(deps, env, info, msg), + ExecuteMsg::Claim { denom } => execute_claim(deps, env, info, denom), + ExecuteMsg::Fund {} => execute_fund_native(deps, env, info), + ExecuteMsg::Receive(msg) => execute_receive(deps, env, info, msg), + ExecuteMsg::UpdateOwnership(action) => execute_update_owner(deps, info, env, action), + ExecuteMsg::Shutdown { denom } => execute_shutdown(deps, info, env, denom), + ExecuteMsg::RegisterRewardDenom { + denom, + emission_rate, + vp_contract, + hook_caller, + withdraw_destination, + } => execute_register_reward_denom( + deps, + info, + denom, + emission_rate, + vp_contract, + hook_caller, + withdraw_destination, + ), + } +} + +/// registers a new denom for rewards distribution. +/// only the owner can register a new denom. +/// a denom can only be registered once; update if you need to change something. +fn execute_register_reward_denom( + deps: DepsMut, + info: MessageInfo, + denom: UncheckedDenom, + emission_rate: RewardEmissionRate, + vp_contract: String, + hook_caller: String, + withdraw_destination: Option, +) -> Result { + // only the owner can register a new denom + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + emission_rate.validate_emission_time_window()?; + + let checked_denom = denom.into_checked(deps.as_ref())?; + let hook_caller = deps.api.addr_validate(&hook_caller)?; + let vp_contract = validate_voting_power_contract(&deps, vp_contract)?; + + let withdraw_destination = match withdraw_destination { + // if withdraw destination is specified, we validate it + Some(addr) => deps.api.addr_validate(&addr)?, + // otherwise default to the owner + None => info.sender, + }; + + // Initialize the reward state + let reward_state = DenomRewardState { + denom: checked_denom, + started_at: Expiration::Never {}, + ends_at: Expiration::Never {}, + emission_rate, + total_earned_puvp: Uint256::zero(), + last_update: Expiration::Never {}, + vp_contract, + hook_caller: hook_caller.clone(), + funded_amount: Uint128::zero(), + withdraw_destination, + }; + let str_denom = reward_state.to_str_denom(); + + // store the new reward denom state or error if it already exists + DENOM_REWARD_STATES.update( + deps.storage, + str_denom.to_string(), + |existing| match existing { + Some(_) => Err(ContractError::DenomAlreadyRegistered {}), + None => Ok(reward_state), + }, + )?; + + // update the registered hooks to include the new denom + subscribe_denom_to_hook(deps, str_denom, hook_caller.clone())?; + + Ok(Response::default()) +} + +/// shutdown the rewards distributor contract. +/// can only be called by the admin and only during the distribution period. +/// this will clawback all (undistributed) future rewards to the admin. +/// updates the period finish expiration to the current block. +fn execute_shutdown( + deps: DepsMut, + info: MessageInfo, + env: Env, + denom: String, +) -> Result { + // only the owner can initiate a shutdown + cw_ownable::assert_owner(deps.storage, &info.sender)?; + + let mut reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.to_string())?; + + // shutdown is only possible during the distribution period + ensure!( + !reward_state.ends_at.is_expired(&env.block), + ContractError::ShutdownError("Reward period already finished".to_string()) + ); + + // we get the start and end scalar values in u64 (seconds/blocks) + let started_at = reward_state.get_started_at_scalar()?; + let ends_at = reward_state.get_ends_at_scalar()?; + let reward_duration = ends_at - started_at; + + // find the % of reward_duration that remains from current block + let passed_units_since_start = match reward_state.emission_rate.duration { + Duration::Height(_) => Uint128::from(env.block.height - started_at), + Duration::Time(_) => Uint128::from(env.block.time.seconds() - started_at), + }; + + // get the fraction of what part of rewards duration is in the past + // and sub from 1 to get the remaining rewards + let remaining_reward_duration_fraction = Decimal::one() + .checked_sub(Decimal::from_ratio( + passed_units_since_start, + reward_duration, + )) + .map_err(|e| ContractError::Std(StdError::overflow(e)))?; + + // to get the clawback msg + let clawback_msg = get_transfer_msg( + reward_state.withdraw_destination.clone(), + reward_state.funded_amount * remaining_reward_duration_fraction, + reward_state.denom.clone(), + )?; + + // shutdown completes the rewards + reward_state.ends_at = match reward_state.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(env.block.height), + Duration::Time(_) => Expiration::AtTime(env.block.time), + }; + + DENOM_REWARD_STATES.save(deps.storage, denom.to_string(), &reward_state)?; + + Ok(Response::new() + .add_attribute("action", "shutdown") + .add_message(clawback_msg)) +} + +fn execute_receive( + deps: DepsMut, + env: Env, + info: MessageInfo, + wrapper: Cw20ReceiveMsg, +) -> Result { + // verify msg + let _msg: ReceiveMsg = from_json(&wrapper.msg)?; + + let reward_denom_state = DENOM_REWARD_STATES.load(deps.storage, info.sender.to_string())?; + execute_fund(deps, env, reward_denom_state, wrapper.amount) +} + +fn execute_fund_native( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + let fund_coin = one_coin(&info).map_err(|_| ContractError::InvalidFunds {})?; + + let reward_denom_state = DENOM_REWARD_STATES.load(deps.storage, fund_coin.denom.clone())?; + + execute_fund(deps, env, reward_denom_state, fund_coin.amount) +} + +fn execute_fund( + deps: DepsMut, + env: Env, + mut denom_reward_state: DenomRewardState, + amount: Uint128, +) -> Result { + // we derive the period for which the rewards are funded + // by looking at the existing reward emission rate and the funded amount + let funded_period_duration = denom_reward_state + .emission_rate + .get_funded_period_duration(amount)?; + let funded_period_value = get_duration_scalar(&funded_period_duration); + + denom_reward_state = denom_reward_state + .bump_funding_date(&env.block) + .bump_last_update(&env.block); + + // the duration of rewards period is extended in different ways, + // depending on the current expiration state and current block + denom_reward_state.ends_at = match denom_reward_state.ends_at { + // if this is the first funding of the denom, the new expiration is the + // funded period duration from the current block + Expiration::Never {} => funded_period_duration.after(&env.block), + // otherwise we add the duration units to the existing expiration + Expiration::AtHeight(h) => { + if h <= env.block.height { + // expiration is the funded duration after current block + Expiration::AtHeight(env.block.height + funded_period_value) + } else { + // if the previous expiration had not yet expired, we extend + // the current rewards period by the newly funded duration + Expiration::AtHeight(h + funded_period_value) + } + } + Expiration::AtTime(t) => { + if t <= env.block.time { + // expiration is the funded duration after current block time + Expiration::AtTime(env.block.time.plus_seconds(funded_period_value)) + } else { + // if the previous expiration had not yet expired, we extend + // the current rewards period by the newly funded duration + Expiration::AtTime(t.plus_seconds(funded_period_value)) + } + } + }; + denom_reward_state.funded_amount += amount; + + DENOM_REWARD_STATES.save( + deps.storage, + denom_reward_state.to_str_denom(), + &denom_reward_state, + )?; + + Ok(Response::default()) +} + +fn execute_claim( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + denom: String, +) -> Result { + // update the rewards information for the sender. + update_rewards(&mut deps, &env, &info.sender, denom.to_string())?; + + // get the denom state for the string-based denom + let denom_reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.to_string())?; + + let mut amount = Uint128::zero(); + + USER_REWARD_STATES.update( + deps.storage, + info.sender.clone(), + |state| -> Result<_, ContractError> { + let mut user_reward_state = state.unwrap_or_default(); + // updating the map returns the previous value if it existed. + // we set the value to zero and store it in the amount defined before the update. + amount = user_reward_state + .pending_denom_rewards + .insert(denom, Uint128::zero()) + .unwrap_or_default(); + Ok(user_reward_state) + }, + )?; + + if amount.is_zero() { + return Err(ContractError::NoRewardsClaimable {}); + } + + Ok(Response::new() + .add_message(get_transfer_msg( + info.sender.clone(), + amount, + denom_reward_state.denom, + )?) + .add_attribute("action", "claim")) +} + +fn execute_update_owner( + deps: DepsMut, + info: MessageInfo, + env: Env, + action: cw_ownable::Action, +) -> Result { + // Update the current contract owner. + // Note, this is a two step process, the new owner must accept this ownership transfer. + // First the owner specifies the new owner, then the new owner must accept. + let ownership = cw_ownable::update_ownership(deps, &env.block, &info.sender, action)?; + Ok(Response::default().add_attributes(ownership.into_attributes())) +} + +pub fn update_rewards(deps: &mut DepsMut, env: &Env, addr: &Addr, denom: String) -> StdResult<()> { + let reward_state = DENOM_REWARD_STATES.load(deps.storage, denom.clone())?; + + // first, we calculate the latest total rewards per unit voting power + // and update them + let total_earned_puvp = get_total_earned_puvp(env, deps.as_ref(), &reward_state)?; + + // update the denom state's total rewards earned and last updated + DENOM_REWARD_STATES.update(deps.storage, denom.clone(), |state| -> StdResult<_> { + match state { + Some(mut rc) => { + rc.total_earned_puvp = total_earned_puvp; + Ok(rc.bump_last_update(&env.block)) + } + None => Err(StdError::generic_err("Denom reward state not found")), + } + })?; + + // then we calculate the rewards earned since last user action + let earned_rewards = get_accrued_rewards_since_last_user_action( + deps.as_ref(), + env, + addr, + total_earned_puvp, + &reward_state.vp_contract, + denom.clone(), + )?; + + // reflect the earned rewards in the user's reward state + USER_REWARD_STATES.update(deps.storage, addr.clone(), |state| -> StdResult<_> { + // if user does not yet have state, create a new one + let mut user_reward_state = state.unwrap_or_default(); + + // get the pre-existing pending reward amount for the denom + let previous_pending_denom_reward_amount = *user_reward_state + .pending_denom_rewards + .get(&denom) + .unwrap_or(&Uint128::zero()); + + // get the amount of newly earned rewards for the denom + let earned_rewards_amount = earned_rewards.get(&denom).cloned().unwrap_or_default(); + + user_reward_state.pending_denom_rewards.insert( + denom.clone(), + previous_pending_denom_reward_amount + earned_rewards_amount, + ); + + // update the user's earned rewards that have been accounted for + user_reward_state + .denom_rewards_puvp + .insert(denom.clone(), total_earned_puvp); + + Ok(user_reward_state) + })?; + Ok(()) +} + +/// Calculate the total rewards earned per unit voting power since the last +/// update. +fn get_total_earned_puvp( + env: &Env, + deps: Deps, + reward_state: &DenomRewardState, +) -> StdResult { + let curr = reward_state.total_earned_puvp; + + // query the total voting power just before this block from the voting power + // contract + let prev_total_power = get_prev_block_total_vp(deps, env, &reward_state.vp_contract)?; + + let last_time_rewards_distributed = + reward_state.get_latest_reward_distribution_time(&env.block); + + // get the duration from the last time rewards were updated to the last time + // rewards were distributed. this will be 0 if the rewards were updated at + // or after the last time rewards were distributed. + let new_reward_distribution_duration = Uint128::from(get_start_end_diff( + last_time_rewards_distributed, + reward_state.last_update, + )?); + + if prev_total_power.is_zero() { + Ok(curr) + } else { + let duration_value = get_duration_scalar(&reward_state.emission_rate.duration); + + // count intervals of the rewards emission that have passed since the + // last update which need to be distributed + let complete_distribution_periods = + new_reward_distribution_duration.checked_div(Uint128::from(duration_value))?; + + // It is impossible for this to overflow as total rewards can never + // exceed max value of Uint128 as total tokens in existence cannot + // exceed Uint128 (because the bank module Coin type uses Uint128). + let new_rewards_distributed = reward_state + .emission_rate + .amount + .full_mul(complete_distribution_periods) + .checked_mul(scale_factor())?; + + // the new rewards per unit voting power that have been distributed + // since the last update + let new_rewards_puvp = new_rewards_distributed.checked_div(prev_total_power.into())?; + Ok(curr + new_rewards_puvp) + } +} + +// get a user's rewards not yet accounted for in their reward state +fn get_accrued_rewards_since_last_user_action( + deps: Deps, + env: &Env, + addr: &Addr, + total_earned_puvp: Uint256, + vp_contract: &Addr, + denom: String, +) -> StdResult> { + // get the user's voting power at the current height + let voting_power = Uint256::from(get_voting_power(deps, env, vp_contract, addr)?); + + let mut accrued_rewards: HashMap = HashMap::new(); + + let user_reward_state = USER_REWARD_STATES + .load(deps.storage, addr.clone()) + .unwrap_or_default(); + + // get previous reward per unit voting power accounted for + let user_last_reward_puvp = user_reward_state + .denom_rewards_puvp + .get(&denom) + .cloned() + .unwrap_or_default(); + + // calculate the difference between the current total reward per unit + // voting power distributed and the user's latest reward per unit voting + // power accounted for + let reward_factor = total_earned_puvp.checked_sub(user_last_reward_puvp)?; + + // calculate the amount of rewards earned: + // voting_power * reward_factor / scale_factor + let accrued_rewards_amount: Uint128 = voting_power + .checked_mul(reward_factor)? + .checked_div(scale_factor())? + .try_into()?; + + accrued_rewards.insert(denom.to_string(), accrued_rewards_amount); + + Ok(accrued_rewards) +} + +fn get_prev_block_total_vp(deps: Deps, env: &Env, contract_addr: &Addr) -> StdResult { + let msg = VotingQueryMsg::TotalPowerAtHeight { + height: Some(env.block.height.checked_sub(1).unwrap_or_default()), + }; + let resp: TotalPowerAtHeightResponse = deps.querier.query_wasm_smart(contract_addr, &msg)?; + Ok(resp.power) +} + +fn get_voting_power( + deps: Deps, + env: &Env, + contract_addr: &Addr, + addr: &Addr, +) -> StdResult { + let msg = VotingQueryMsg::VotingPowerAtHeight { + address: addr.into(), + height: Some(env.block.height), + }; + let resp: VotingPowerAtHeightResponse = deps.querier.query_wasm_smart(contract_addr, &msg)?; + Ok(resp.power) +} + +/// returns underlying scalar value for a given duration. +/// if the duration is in blocks, returns the block height. +/// if the duration is in time, returns the time in seconds. +fn get_duration_scalar(duration: &Duration) -> u64 { + match duration { + Duration::Height(h) => *h, + Duration::Time(t) => *t, + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Info {} => Ok(to_json_binary(&query_info(deps)?)?), + QueryMsg::RewardsState {} => Ok(to_json_binary(&query_rewards_state(deps, env)?)?), + QueryMsg::GetPendingRewards { address } => { + Ok(to_json_binary(&query_pending_rewards(deps, env, address)?)?) + } + QueryMsg::Ownership {} => to_json_binary(&cw_ownable::get_ownership(deps.storage)?), + QueryMsg::DenomRewardState { denom } => { + let state = DENOM_REWARD_STATES.load(deps.storage, denom)?; + Ok(to_json_binary(&state)?) + } + } +} + +fn query_info(deps: Deps) -> StdResult { + let info = get_contract_version(deps.storage)?; + Ok(InfoResponse { info }) +} + +fn query_rewards_state(deps: Deps, _env: Env) -> StdResult { + let rewards = DENOM_REWARD_STATES + .range(deps.storage, None, None, Order::Ascending) + .map(|item| item.map(|(_, v)| v)) + .collect::>>()?; + Ok(RewardsStateResponse { rewards }) +} + +fn query_pending_rewards(deps: Deps, env: Env, addr: String) -> StdResult { + let addr = deps.api.addr_validate(&addr)?; + let reward_states = DENOM_REWARD_STATES + .range(deps.storage, None, None, Order::Ascending) + .collect::>>()?; + + let mut pending_rewards: HashMap = HashMap::new(); + + for (denom, reward_state) in reward_states { + let total_earned_puvp = get_total_earned_puvp(&env, deps, &reward_state)?; + + let earned_rewards = get_accrued_rewards_since_last_user_action( + deps, + &env, + &addr, + total_earned_puvp, + &reward_state.vp_contract, + denom.to_string(), + )?; + + let user_reward_state = USER_REWARD_STATES + .load(deps.storage, addr.clone()) + .unwrap_or_default(); + + let default_amt = Uint128::zero(); + let earned_amount = earned_rewards.get(&denom).unwrap_or(&default_amt); + let existing_amount = user_reward_state + .pending_denom_rewards + .get(&denom) + .unwrap_or(&default_amt); + pending_rewards.insert(denom, *earned_amount + *existing_amount); + } + + let pending_rewards_response = PendingRewardsResponse { + address: addr.to_string(), + pending_rewards, + }; + Ok(pending_rewards_response) +} + +/// Returns the appropriate CosmosMsg for transferring the reward token. +fn get_transfer_msg(recipient: Addr, amount: Uint128, denom: Denom) -> StdResult { + match denom { + Denom::Native(denom) => Ok(BankMsg::Send { + to_address: recipient.into_string(), + amount: coins(amount.u128(), denom), + } + .into()), + Denom::Cw20(addr) => { + let cw20_msg = to_json_binary(&cw20::Cw20ExecuteMsg::Transfer { + recipient: recipient.into_string(), + amount, + })?; + Ok(WasmMsg::Execute { + contract_addr: addr.into_string(), + msg: cw20_msg, + funds: vec![], + } + .into()) + } + } +} + +pub(crate) fn scale_factor() -> Uint256 { + Uint256::from(10u8).pow(39) +} + +/// Calculate the duration from start to end. If the end is at or before the +/// start, return 0. +fn get_start_end_diff(end: Expiration, start: Expiration) -> StdResult { + match (end, start) { + (Expiration::AtHeight(end), Expiration::AtHeight(start)) => { + if end > start { + Ok(end - start) + } else { + Ok(0) + } + } + (Expiration::AtTime(end), Expiration::AtTime(start)) => { + if end > start { + Ok(end.seconds() - start.seconds()) + } else { + Ok(0) + } + } + (Expiration::Never {}, Expiration::Never {}) => Ok(0), + _ => Err(StdError::generic_err(format!( + "incompatible expirations: got end {:?}, start {:?}", + end, start + ))), + } +} + +fn validate_voting_power_contract( + deps: &DepsMut, + vp_contract: String, +) -> Result { + let vp_contract = deps.api.addr_validate(&vp_contract)?; + let _: TotalPowerAtHeightResponse = deps.querier.query_wasm_smart( + &vp_contract, + &VotingQueryMsg::TotalPowerAtHeight { height: None }, + )?; + Ok(vp_contract) +} diff --git a/contracts/distribution/dao-rewards-distributor/src/error.rs b/contracts/distribution/dao-rewards-distributor/src/error.rs new file mode 100644 index 000000000..68e661bb2 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/error.rs @@ -0,0 +1,41 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error(transparent)] + Std(#[from] StdError), + + #[error(transparent)] + Ownable(#[from] cw_ownable::OwnershipError), + + #[error(transparent)] + Cw20Error(#[from] cw20_base::ContractError), + + #[error("Invalid Cw20")] + InvalidCw20 {}, + + #[error("Invalid funds")] + InvalidFunds {}, + + #[error("Staking change hook sender is not staking contract")] + InvalidHookSender {}, + + #[error("No rewards claimable")] + NoRewardsClaimable {}, + + #[error("Reward period not finished")] + RewardPeriodNotFinished {}, + + #[error("Reward rate less then one per block")] + RewardRateLessThenOnePerBlock {}, + + #[error("Reward duration can not be zero")] + ZeroRewardDuration {}, + + #[error("Rewards distributor shutdown error: {0}")] + ShutdownError(String), + + #[error("Denom already registered")] + DenomAlreadyRegistered {}, +} diff --git a/contracts/distribution/dao-rewards-distributor/src/hooks.rs b/contracts/distribution/dao-rewards-distributor/src/hooks.rs new file mode 100644 index 000000000..d57185f12 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/hooks.rs @@ -0,0 +1,113 @@ +use cosmwasm_std::{Addr, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; +use cw4::MemberChangedHookMsg; +use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg}; + +use crate::{contract::update_rewards, state::REGISTERED_HOOK_DENOMS, ContractError}; + +/// Register a hook caller contract for a given denom. +pub(crate) fn subscribe_denom_to_hook( + deps: DepsMut, + denom: String, + hook: Addr, +) -> Result<(), ContractError> { + REGISTERED_HOOK_DENOMS.update(deps.storage, hook, |denoms| -> StdResult<_> { + let mut denoms = denoms.unwrap_or_default(); + denoms.push(denom.to_string()); + Ok(denoms) + })?; + Ok(()) +} + +/// Ensures hooks that update voting power are only called by a designated +/// hook_caller contract. +/// Returns a list of denoms that the hook caller is registered for. +pub(crate) fn get_hook_caller_registered_denoms( + deps: Deps, + info: MessageInfo, +) -> Result, ContractError> { + // only a designated hook_caller contract can call this hook. + // failing to load the registered denoms for a given hook returns an error. + REGISTERED_HOOK_DENOMS + .load(deps.storage, info.sender.clone()) + .map_err(|_| ContractError::InvalidHookSender {}) +} + +pub(crate) fn execute_stake_changed( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: StakeChangedHookMsg, +) -> Result { + // Check that the sender is the vp_contract (or the hook_caller if configured). + let hooked_denoms = get_hook_caller_registered_denoms(deps.as_ref(), info)?; + + match msg { + StakeChangedHookMsg::Stake { addr, .. } => execute_stake(deps, env, addr, hooked_denoms), + StakeChangedHookMsg::Unstake { addr, .. } => { + execute_unstake(deps, env, addr, hooked_denoms) + } + } +} + +pub(crate) fn execute_membership_changed( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + msg: MemberChangedHookMsg, +) -> Result { + // Check that the sender is the vp_contract (or the hook_caller if configured). + let hooked_denoms = get_hook_caller_registered_denoms(deps.as_ref(), info)?; + + // Get the addresses of members whose voting power has changed. + for member in msg.diffs { + let addr = deps.api.addr_validate(&member.key)?; + for denom in hooked_denoms.clone() { + update_rewards(&mut deps, &env, &addr, denom)?; + } + } + + Ok(Response::new().add_attribute("action", "membership_changed")) +} + +pub(crate) fn execute_nft_stake_changed( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: NftStakeChangedHookMsg, +) -> Result { + // Check that the sender is the vp_contract (or the hook_caller if configured). + let hooked_denoms = get_hook_caller_registered_denoms(deps.as_ref(), info)?; + + match msg { + NftStakeChangedHookMsg::Stake { addr, .. } => execute_stake(deps, env, addr, hooked_denoms), + NftStakeChangedHookMsg::Unstake { addr, .. } => { + execute_unstake(deps, env, addr, hooked_denoms) + } + } +} + +pub(crate) fn execute_stake( + mut deps: DepsMut, + env: Env, + addr: Addr, + hooked_denoms: Vec, +) -> Result { + // update rewards for every denom that the hook caller is registered for + for denom in hooked_denoms { + update_rewards(&mut deps, &env, &addr, denom)?; + } + Ok(Response::new().add_attribute("action", "stake")) +} + +pub(crate) fn execute_unstake( + mut deps: DepsMut, + env: Env, + addr: Addr, + hooked_denoms: Vec, +) -> Result { + // update rewards for every denom that the hook caller is registered for + for denom in hooked_denoms { + update_rewards(&mut deps, &env, &addr, denom)?; + } + Ok(Response::new().add_attribute("action", "unstake")) +} diff --git a/contracts/distribution/dao-rewards-distributor/src/lib.rs b/contracts/distribution/dao-rewards-distributor/src/lib.rs new file mode 100644 index 000000000..51ae5c619 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/lib.rs @@ -0,0 +1,12 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod hooks; +pub mod msg; +pub mod state; + +#[cfg(test)] +mod testing; + +pub use crate::error::ContractError; diff --git a/contracts/distribution/dao-rewards-distributor/src/msg.rs b/contracts/distribution/dao-rewards-distributor/src/msg.rs new file mode 100644 index 000000000..e2d41c112 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/msg.rs @@ -0,0 +1,141 @@ +use std::collections::HashMap; + +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{StdError, StdResult, Uint128, Uint256}; +use cw20::{Cw20ReceiveMsg, UncheckedDenom}; +use cw4::MemberChangedHookMsg; +use cw_ownable::cw_ownable_execute; +use cw_utils::Duration; +use dao_hooks::{nft_stake::NftStakeChangedHookMsg, stake::StakeChangedHookMsg}; +use dao_interface::voting::InfoResponse; + +use crate::{state::DenomRewardState, ContractError}; + +// so that consumers don't need a cw_ownable or cw_controllers dependency +// to consume this contract's queries. +pub use cw_controllers::ClaimsResponse; +pub use cw_ownable::Ownership; + +#[cw_serde] +pub struct InstantiateMsg { + /// The owner of the contract. Is able to fund the contract and update + /// the reward duration. + pub owner: Option, +} + +#[cw_ownable_execute] +#[cw_serde] +pub enum ExecuteMsg { + /// Called when a member is added or removed + /// to a cw4-groups or cw721-roles contract. + MemberChangedHook(MemberChangedHookMsg), + /// Called when NFTs are staked or unstaked. + NftStakeChangeHook(NftStakeChangedHookMsg), + /// Called when tokens are staked or unstaked. + StakeChangeHook(StakeChangedHookMsg), + /// Claims rewards for the sender. + Claim { denom: String }, + /// Used to fund this contract with cw20 tokens. + Receive(Cw20ReceiveMsg), + /// Used to fund this contract with native tokens. + Fund {}, + /// shuts down the rewards distributor. withdraws all future staking rewards + /// back to the treasury. members can claim whatever they earned until this point. + Shutdown { denom: String }, + /// registers a new reward denom + RegisterRewardDenom { + denom: UncheckedDenom, + emission_rate: RewardEmissionRate, + vp_contract: String, + hook_caller: String, + withdraw_destination: Option, + }, +} + +/// defines how many tokens (amount) should be distributed per amount of time +/// (duration). e.g. 5udenom per hour. +#[cw_serde] +pub struct RewardEmissionRate { + pub amount: Uint128, + pub duration: Duration, +} + +impl RewardEmissionRate { + pub fn validate_emission_time_window(&self) -> Result<(), ContractError> { + // Reward duration must be greater than 0 + if let Duration::Height(0) | Duration::Time(0) = self.duration { + return Err(ContractError::ZeroRewardDuration {}); + } + Ok(()) + } + + // find the duration of the funded period given emission config and funded amount + pub fn get_funded_period_duration(&self, funded_amount: Uint128) -> StdResult { + let funded_amount_u256 = Uint256::from(funded_amount); + let amount_u256 = Uint256::from(self.amount); + let amount_to_emission_rate_ratio = funded_amount_u256.checked_div(amount_u256)?; + + let ratio_str = amount_to_emission_rate_ratio.to_string(); + let ratio = ratio_str + .parse::() + .map_err(|e| StdError::generic_err(e.to_string()))?; + + let funded_period_duration = match self.duration { + Duration::Height(h) => { + let duration_height = match ratio.checked_mul(h) { + Some(duration) => duration, + None => return Err(StdError::generic_err("overflow")), + }; + Duration::Height(duration_height) + } + Duration::Time(t) => { + let duration_time = match ratio.checked_mul(t) { + Some(duration) => duration, + None => return Err(StdError::generic_err("overflow")), + }; + Duration::Time(duration_time) + } + }; + + Ok(funded_period_duration) + } +} + +#[cw_serde] +pub enum MigrateMsg {} + +#[cw_serde] +pub enum ReceiveMsg { + /// Used to fund this contract with cw20 tokens. + Fund {}, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Returns contract version info + #[returns(InfoResponse)] + Info {}, + /// Returns the state of the registered reward distributions. + #[returns(RewardsStateResponse)] + RewardsState {}, + /// Returns the pending rewards for the given address. + #[returns(PendingRewardsResponse)] + GetPendingRewards { address: String }, + /// Returns information about the ownership of this contract. + #[returns(::cw_ownable::Ownership<::cosmwasm_std::Addr>)] + Ownership {}, + #[returns(DenomRewardState)] + DenomRewardState { denom: String }, +} + +#[cw_serde] +pub struct RewardsStateResponse { + pub rewards: Vec, +} + +#[cw_serde] +pub struct PendingRewardsResponse { + pub address: String, + pub pending_rewards: HashMap, +} diff --git a/contracts/distribution/dao-rewards-distributor/src/state.rs b/contracts/distribution/dao-rewards-distributor/src/state.rs new file mode 100644 index 000000000..1489266a3 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/state.rs @@ -0,0 +1,163 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{ensure, Addr, BlockInfo, StdError, StdResult, Uint128, Uint256}; +use cw20::{Denom, Expiration}; +use cw_storage_plus::Map; +use cw_utils::Duration; +use std::{cmp::min, collections::HashMap}; + +use crate::{msg::RewardEmissionRate, ContractError}; + +/// map user address to their unique reward state +pub const USER_REWARD_STATES: Map = Map::new("u_r_s"); + +/// map denom string to the state of its reward distribution +pub const DENOM_REWARD_STATES: Map = Map::new("d_r_s"); + +/// map registered hooks to list of denoms they're registered for +pub const REGISTERED_HOOK_DENOMS: Map> = Map::new("r_h_d"); + +#[cw_serde] +#[derive(Default)] +pub struct UserRewardState { + /// map denom to the user's pending rewards + pub pending_denom_rewards: HashMap, + /// map denom string to the user's earned rewards per unit voting power that + /// have already been accounted for in pending rewards and potentially + /// claimed + pub denom_rewards_puvp: HashMap, +} + +/// the state of a denom's reward distribution +#[cw_serde] +pub struct DenomRewardState { + /// validated denom (native or cw20) + pub denom: Denom, + /// the time when the current reward distribution period started. period + /// finishes iff it reaches its end. + pub started_at: Expiration, + /// the time when all funded rewards are allocated to users and thus the + /// distribution period ends. + pub ends_at: Expiration, + /// reward emission rate + pub emission_rate: RewardEmissionRate, + /// total rewards earned per unit voting power from started_at to + /// last_update + pub total_earned_puvp: Uint256, + /// time when total_earned_puvp was last updated for this denom + pub last_update: Expiration, + /// address to query the voting power + pub vp_contract: Addr, + /// address that will update the reward split when the voting power + /// distribution changes + pub hook_caller: Addr, + /// total amount of rewards funded + pub funded_amount: Uint128, + /// optional destination address for reward clawbacks + pub withdraw_destination: Addr, +} + +impl DenomRewardState { + pub fn bump_last_update(mut self, current_block: &BlockInfo) -> Self { + self.last_update = match self.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(current_block.height), + Duration::Time(_) => Expiration::AtTime(current_block.time), + }; + self + } + + /// tries to update the last funding date. + /// if distribution expiration is in the future, nothing changes. + /// if distribution expiration is in the past, or had never been set, + /// funding date becomes the current block. + pub fn bump_funding_date(mut self, current_block: &BlockInfo) -> Self { + // if its never been set before, we set it to current block and return + if let Expiration::Never {} = self.started_at { + self.started_at = match self.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(current_block.height), + Duration::Time(_) => Expiration::AtTime(current_block.time), + }; + return self; + } + + // if current distribution is expired, we set the funding date + // to the current date + if self.ends_at.is_expired(current_block) { + self.started_at = match self.emission_rate.duration { + Duration::Height(_) => Expiration::AtHeight(current_block.height), + Duration::Time(_) => Expiration::AtTime(current_block.time), + }; + } + + self + } + + pub fn to_str_denom(&self) -> String { + match &self.denom { + Denom::Native(denom) => denom.to_string(), + Denom::Cw20(address) => address.to_string(), + } + } + + /// Returns the ends_at time value as a u64. + /// - If `Never`, returns an error. + /// - If `AtHeight(h)`, the value is `h`. + /// - If `AtTime(t)`, the value is `t`, where t is seconds. + pub fn get_ends_at_scalar(&self) -> StdResult { + match self.ends_at { + Expiration::Never {} => Err(StdError::generic_err("reward period is not active")), + Expiration::AtHeight(h) => Ok(h), + Expiration::AtTime(t) => Ok(t.seconds()), + } + } + + /// Returns the started_at time value as a u64. + /// - If `Never`, returns an error. + /// - If `AtHeight(h)`, the value is `h`. + /// - If `AtTime(t)`, the value is `t`, where t is seconds. + pub fn get_started_at_scalar(&self) -> StdResult { + match self.started_at { + Expiration::AtHeight(h) => Ok(h), + Expiration::AtTime(t) => Ok(t.seconds()), + Expiration::Never {} => Err(StdError::generic_err("reward period is not active")), + } + } + + /// Returns the latest time when rewards were distributed. Works by + /// comparing `current_block` with the distribution end time: + /// - If the end is `Never`, then no rewards are being distributed, thus we + /// return `Never`. + /// - If the end is `AtHeight(h)` or `AtTime(t)`, we compare the current + /// block height or time with `h` or `t` respectively. + /// - If current block respective value is before the end, rewards are still + /// being distributed. We therefore return the current block `height` or + /// `time`, as this block is the most recent time rewards were distributed. + /// - If current block respective value is after the end, rewards are no + /// longer being distributed. We therefore return the end `height` or + /// `time`, as that was the last date where rewards were distributed. + pub fn get_latest_reward_distribution_time(&self, current_block: &BlockInfo) -> Expiration { + match self.ends_at { + Expiration::Never {} => Expiration::Never {}, + Expiration::AtHeight(h) => Expiration::AtHeight(min(current_block.height, h)), + Expiration::AtTime(t) => Expiration::AtTime(min(current_block.time, t)), + } + } + + /// Returns `ContractError::RewardPeriodNotFinished` if the period finish + /// expiration is of either `AtHeight` or `AtTime` variant and is earlier + /// than the current block height or time respectively. + pub fn validate_period_finish_expiration_if_set( + &self, + current_block: &BlockInfo, + ) -> Result<(), ContractError> { + match self.ends_at { + Expiration::AtHeight(_) | Expiration::AtTime(_) => { + ensure!( + self.ends_at.is_expired(current_block), + ContractError::RewardPeriodNotFinished {} + ); + Ok(()) + } + Expiration::Never {} => Ok(()), + } + } +} diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/mod.rs b/contracts/distribution/dao-rewards-distributor/src/testing/mod.rs new file mode 100644 index 000000000..f0b1d66ab --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/testing/mod.rs @@ -0,0 +1,298 @@ +use cosmwasm_std::Empty; +use cw_multi_test::{Contract, ContractWrapper}; + +pub mod suite; +pub mod tests; + +pub const DENOM: &str = "ujuno"; +pub const ALT_DENOM: &str = "unotjuno"; +pub const OWNER: &str = "owner"; +pub const ADDR1: &str = "addr0001"; +pub const ADDR2: &str = "addr0002"; +pub const ADDR3: &str = "addr0003"; + +pub fn contract_rewards() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ); + Box::new(contract) +} + +mod cw4_setup { + use cosmwasm_std::Addr; + use cw4::Member; + use cw_multi_test::{App, Executor}; + use dao_testing::contracts::{cw4_group_contract, dao_voting_cw4_contract}; + + use super::OWNER; + + pub fn setup_cw4_test(app: &mut App, initial_members: Vec) -> (Addr, Addr) { + let cw4_group_code_id = app.store_code(cw4_group_contract()); + let vp_code_id = app.store_code(dao_voting_cw4_contract()); + + let msg = dao_voting_cw4::msg::InstantiateMsg { + group_contract: dao_voting_cw4::msg::GroupContract::New { + cw4_group_code_id, + initial_members, + }, + }; + + let vp_addr = app + .instantiate_contract( + vp_code_id, + Addr::unchecked(OWNER), + &msg, + &[], + "cw4-vp", + None, + ) + .unwrap(); + + let cw4_addr: Addr = app + .wrap() + .query_wasm_smart( + vp_addr.clone(), + &dao_voting_cw4::msg::QueryMsg::GroupContract {}, + ) + .unwrap(); + + (vp_addr, cw4_addr) + } +} + +mod native_setup { + use cosmwasm_std::{coins, Addr}; + use cw_multi_test::{App, Executor}; + use dao_testing::contracts::native_staked_balances_voting_contract; + + use super::{DENOM, OWNER}; + + pub fn stake_tokenfactory_tokens( + app: &mut App, + staking_addr: &Addr, + address: &str, + amount: u128, + ) { + let msg = dao_voting_token_staked::msg::ExecuteMsg::Stake {}; + app.execute_contract( + Addr::unchecked(address), + staking_addr.clone(), + &msg, + &coins(amount, DENOM), + ) + .unwrap(); + } + + pub fn unstake_tokenfactory_tokens( + app: &mut App, + staking_addr: &Addr, + address: &str, + amount: u128, + ) { + let msg = dao_voting_token_staked::msg::ExecuteMsg::Unstake { + amount: amount.into(), + }; + app.execute_contract(Addr::unchecked(address), staking_addr.clone(), &msg, &[]) + .unwrap(); + } + + pub fn setup_native_token_test(app: &mut App) -> Addr { + let vp_code_id = app.store_code(native_staked_balances_voting_contract()); + + let msg = dao_voting_token_staked::msg::InstantiateMsg { + active_threshold: None, + unstaking_duration: None, + token_info: dao_voting_token_staked::msg::TokenInfo::Existing { + denom: DENOM.to_string(), + }, + }; + + app.instantiate_contract( + vp_code_id, + Addr::unchecked(OWNER), + &msg, + &[], + "native-vp", + None, + ) + .unwrap() + } +} + +mod cw20_setup { + use cosmwasm_std::{to_json_binary, Addr, Uint128}; + use cw20::Cw20Coin; + use cw_multi_test::{App, Executor}; + use cw_utils::Duration; + use dao_testing::contracts::{ + cw20_base_contract, cw20_stake_contract, cw20_staked_balances_voting_contract, + }; + + use super::OWNER; + + pub fn instantiate_cw20(app: &mut App, name: &str, initial_balances: Vec) -> Addr { + let cw20_id = app.store_code(cw20_base_contract()); + let msg = cw20_base::msg::InstantiateMsg { + name: name.to_string(), + symbol: name.to_string(), + decimals: 6, + initial_balances, + mint: None, + marketing: None, + }; + + app.instantiate_contract(cw20_id, Addr::unchecked(OWNER), &msg, &[], "cw20", None) + .unwrap() + } + + pub fn instantiate_cw20_staking( + app: &mut App, + cw20: Addr, + unstaking_duration: Option, + ) -> Addr { + let staking_code_id = app.store_code(cw20_stake_contract()); + let msg = cw20_stake::msg::InstantiateMsg { + owner: Some(OWNER.to_string()), + token_address: cw20.to_string(), + unstaking_duration, + }; + app.instantiate_contract( + staking_code_id, + Addr::unchecked(OWNER), + &msg, + &[], + "staking", + None, + ) + .unwrap() + } + + pub fn instantiate_cw20_vp_contract(app: &mut App, cw20: Addr, staking_contract: Addr) -> Addr { + let vp_code_id = app.store_code(cw20_staked_balances_voting_contract()); + let msg = dao_voting_cw20_staked::msg::InstantiateMsg { + token_info: dao_voting_cw20_staked::msg::TokenInfo::Existing { + address: cw20.to_string(), + staking_contract: dao_voting_cw20_staked::msg::StakingInfo::Existing { + staking_contract_address: staking_contract.to_string(), + }, + }, + active_threshold: None, + }; + app.instantiate_contract(vp_code_id, Addr::unchecked(OWNER), &msg, &[], "vp", None) + .unwrap() + } + + pub fn setup_cw20_test(app: &mut App, initial_balances: Vec) -> (Addr, Addr, Addr) { + // Instantiate cw20 contract + let cw20_addr = instantiate_cw20(app, "test", initial_balances.clone()); + + // Instantiate staking contract + let staking_addr = instantiate_cw20_staking(app, cw20_addr.clone(), None); + + // Instantiate vp contract + let vp_addr = instantiate_cw20_vp_contract(app, cw20_addr.clone(), staking_addr.clone()); + + (staking_addr, cw20_addr, vp_addr) + } + + #[allow(dead_code)] + pub fn stake_cw20_tokens>( + app: &mut App, + staking_addr: &Addr, + cw20_addr: &Addr, + sender: T, + amount: u128, + ) { + let msg = cw20::Cw20ExecuteMsg::Send { + contract: staking_addr.to_string(), + amount: Uint128::new(amount), + msg: to_json_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }; + app.execute_contract(Addr::unchecked(sender), cw20_addr.clone(), &msg, &[]) + .unwrap(); + } +} + +mod cw721_setup { + + use cosmwasm_std::{to_json_binary, Addr, Binary, Empty}; + use cw_multi_test::{App, Executor}; + use dao_testing::contracts::{cw721_base_contract, voting_cw721_staked_contract}; + use dao_voting_cw721_staked::state::Config; + + use super::OWNER; + + pub fn stake_cw721( + app: &mut App, + vp_addr: &Addr, + cw721_addr: &Addr, + address: &str, + token_id: &str, + ) { + let msg = cw721_base::msg::ExecuteMsg::::SendNft { + contract: vp_addr.to_string(), + token_id: token_id.to_string(), + msg: Binary::default(), + }; + + app.execute_contract(Addr::unchecked(address), cw721_addr.clone(), &msg, &[]) + .unwrap(); + } + + pub fn unstake_cw721(app: &mut App, vp_addr: &Addr, address: &str, token_id: &str) { + app.execute_contract( + Addr::unchecked(address), + vp_addr.clone(), + &dao_voting_cw721_staked::msg::ExecuteMsg::Unstake { + token_ids: vec![token_id.to_string()], + }, + &[], + ) + .unwrap(); + } + + pub fn setup_cw721_test(app: &mut App, initial_nfts: Vec) -> (Addr, Addr) { + let cw721_code_id = app.store_code(cw721_base_contract()); + let vp_code_id = app.store_code(voting_cw721_staked_contract()); + + let msg = dao_voting_cw721_staked::msg::InstantiateMsg { + nft_contract: dao_voting_cw721_staked::msg::NftContract::New { + code_id: cw721_code_id, + label: "Test NFT contract".to_string(), + msg: to_json_binary(&cw721_base::msg::InstantiateMsg { + name: "Test NFT".to_string(), + symbol: "TEST".to_string(), + minter: OWNER.to_string(), + }) + .unwrap(), + initial_nfts, + }, + active_threshold: None, + unstaking_duration: None, + }; + + let vp_addr = app + .instantiate_contract( + vp_code_id, + Addr::unchecked(OWNER), + &msg, + &[], + "cw721-vp", + None, + ) + .unwrap(); + + let cw721_addr = app + .wrap() + .query_wasm_smart::( + vp_addr.clone(), + &dao_voting_cw721_staked::msg::QueryMsg::Config {}, + ) + .unwrap() + .nft_address; + + (vp_addr, cw721_addr) + } +} diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs new file mode 100644 index 000000000..2deb7b8f2 --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/testing/suite.rs @@ -0,0 +1,720 @@ +use std::borrow::BorrowMut; + +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{coin, coins, to_json_binary, Addr, Coin, Empty, Timestamp, Uint128}; +use cw20::{Cw20Coin, Expiration, UncheckedDenom}; +use cw20_stake::msg::ReceiveMsg; +use cw4::{Member, MemberListResponse}; +use cw_multi_test::{App, BankSudo, Executor, SudoMsg}; +use cw_ownable::{Action, Ownership}; +use cw_utils::Duration; + +use crate::{ + msg::{ + ExecuteMsg, InstantiateMsg, PendingRewardsResponse, QueryMsg, RewardEmissionRate, + RewardsStateResponse, + }, + state::DenomRewardState, + testing::cw20_setup::instantiate_cw20, +}; + +use super::{ + contract_rewards, + cw20_setup::{self, setup_cw20_test}, + cw4_setup::setup_cw4_test, + cw721_setup::{setup_cw721_test, stake_cw721, unstake_cw721}, + native_setup::{ + setup_native_token_test, stake_tokenfactory_tokens, unstake_tokenfactory_tokens, + }, + ADDR1, ADDR2, ADDR3, DENOM, OWNER, +}; + +pub enum DaoType { + CW20, + CW721, + Native, + CW4, +} + +#[cw_serde] +pub struct RewardsConfig { + pub amount: u128, + pub denom: UncheckedDenom, + pub duration: Duration, + pub destination: Option, +} + +pub struct SuiteBuilder { + pub _instantiate: InstantiateMsg, + pub dao_type: DaoType, + pub rewards_config: RewardsConfig, +} + +impl SuiteBuilder { + pub fn base(dao_type: DaoType) -> Self { + Self { + _instantiate: InstantiateMsg { + owner: Some(OWNER.to_string()), + }, + dao_type, + rewards_config: RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Native(DENOM.to_string()), + duration: Duration::Height(10), + destination: None, + }, + } + } + + pub fn with_rewards_config(mut self, rewards_config: RewardsConfig) -> Self { + self.rewards_config = rewards_config; + self + } + + pub fn with_withdraw_destination(mut self, withdraw_destination: Option) -> Self { + self.rewards_config.destination = withdraw_destination; + self + } +} + +impl SuiteBuilder { + pub fn build(mut self) -> Suite { + let owner = Addr::unchecked(OWNER); + + let mut suite_built = Suite { + app: App::default(), + owner: Some(owner.clone()), + staking_addr: Addr::unchecked(""), + voting_power_addr: Addr::unchecked(""), + distribution_contract: Addr::unchecked(""), + cw20_addr: Addr::unchecked(""), + reward_denom: DENOM.to_string(), + }; + + // start at 0 height and time + suite_built.app.borrow_mut().update_block(|b| b.height = 0); + suite_built + .app + .borrow_mut() + .update_block(|b| b.time = Timestamp::from_seconds(0)); + + match self.dao_type { + DaoType::CW4 => { + let members = vec![ + Member { + addr: ADDR1.to_string(), + weight: 2, + }, + Member { + addr: ADDR2.to_string(), + weight: 1, + }, + Member { + addr: ADDR3.to_string(), + weight: 1, + }, + ]; + + let (voting_power_addr, dao_voting_addr) = + setup_cw4_test(suite_built.app.borrow_mut(), members); + suite_built.voting_power_addr = voting_power_addr.clone(); + suite_built.staking_addr = dao_voting_addr.clone(); + } + DaoType::CW20 => { + let initial_balances = vec![ + Cw20Coin { + address: ADDR1.to_string(), + amount: Uint128::new(100), + }, + Cw20Coin { + address: ADDR2.to_string(), + amount: Uint128::new(50), + }, + Cw20Coin { + address: ADDR3.to_string(), + amount: Uint128::new(50), + }, + ]; + + let (staking_addr, cw20_addr, vp_addr) = + setup_cw20_test(suite_built.app.borrow_mut(), initial_balances.clone()); + + suite_built.voting_power_addr = vp_addr.clone(); + suite_built.cw20_addr = cw20_addr.clone(); + suite_built.staking_addr = staking_addr.clone(); + + for coin in initial_balances.clone() { + suite_built.stake_cw20_tokens(coin.amount.u128(), coin.address.as_str()); + } + } + DaoType::CW721 => { + let initial_nfts = vec![ + to_json_binary(&cw721_base::msg::ExecuteMsg::::Mint { + token_id: "1".to_string(), + owner: ADDR1.to_string(), + token_uri: Some("https://jpegs.com".to_string()), + extension: Empty {}, + }) + .unwrap(), + to_json_binary(&cw721_base::msg::ExecuteMsg::::Mint { + token_id: "2".to_string(), + owner: ADDR1.to_string(), + token_uri: Some("https://jpegs.com".to_string()), + extension: Empty {}, + }) + .unwrap(), + to_json_binary(&cw721_base::msg::ExecuteMsg::::Mint { + token_id: "3".to_string(), + owner: ADDR2.to_string(), + token_uri: Some("https://jpegs.com".to_string()), + extension: Empty {}, + }) + .unwrap(), + to_json_binary(&cw721_base::msg::ExecuteMsg::::Mint { + token_id: "4".to_string(), + owner: ADDR3.to_string(), + token_uri: Some("https://jpegs.com".to_string()), + extension: Empty {}, + }) + .unwrap(), + ]; + + let (vp_addr, cw721) = setup_cw721_test(suite_built.app.borrow_mut(), initial_nfts); + + suite_built.voting_power_addr = vp_addr.clone(); + suite_built.staking_addr = cw721.clone(); + + suite_built.stake_nft(ADDR1, 1); + suite_built.stake_nft(ADDR1, 2); + suite_built.stake_nft(ADDR2, 3); + suite_built.stake_nft(ADDR3, 4); + } + DaoType::Native => { + let initial_balances = vec![ + (ADDR1, coins(100, DENOM)), + (ADDR2, coins(50, DENOM)), + (ADDR3, coins(50, DENOM)), + ]; + + // Mint tokens for initial balances + for init_bal in initial_balances { + suite_built + .app + .borrow_mut() + .sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: init_bal.0.to_string(), + amount: init_bal.1, + } + })) + .unwrap(); + } + + // Create Native token staking contract + let vp_addr = setup_native_token_test(suite_built.app.borrow_mut()); + suite_built.voting_power_addr = vp_addr.clone(); + suite_built.staking_addr = vp_addr.clone(); + suite_built.stake_native_tokens(ADDR1, 100); + suite_built.stake_native_tokens(ADDR2, 50); + suite_built.stake_native_tokens(ADDR3, 50); + } + }; + + // initialize the rewards distributor + let reward_code_id = suite_built.app.borrow_mut().store_code(contract_rewards()); + let reward_addr = suite_built + .app + .borrow_mut() + .instantiate_contract( + reward_code_id, + owner.clone(), + &InstantiateMsg { + owner: Some(owner.clone().into_string()), + }, + &[], + "reward", + None, + ) + .unwrap(); + suite_built.distribution_contract = reward_addr.clone(); + + // depending on the dao type we register rewards differently + match self.dao_type { + DaoType::CW721 => { + suite_built.register_hook(suite_built.voting_power_addr.clone()); + suite_built.register_reward_denom( + self.rewards_config.clone(), + suite_built.voting_power_addr.to_string().as_ref(), + ); + match self.rewards_config.denom { + UncheckedDenom::Native(_) => { + suite_built.fund_distributor_native(coin(100_000_000, DENOM.to_string())); + } + UncheckedDenom::Cw20(_) => { + suite_built.fund_distributor_cw20(Cw20Coin { + address: suite_built.cw20_addr.to_string(), + amount: Uint128::new(100_000_000), + }); + } + }; + } + _ => { + self.rewards_config.denom = match self.rewards_config.denom { + UncheckedDenom::Native(denom) => UncheckedDenom::Native(denom), + UncheckedDenom::Cw20(_) => UncheckedDenom::Cw20( + instantiate_cw20( + suite_built.app.borrow_mut(), + "rewardcw", + vec![Cw20Coin { + address: OWNER.to_string(), + amount: Uint128::new(1_000_000_000), + }], + ) + .to_string(), + ), + }; + suite_built.reward_denom = match self.rewards_config.denom.clone() { + UncheckedDenom::Native(denom) => denom, + UncheckedDenom::Cw20(addr) => addr, + }; + + suite_built.register_hook(suite_built.staking_addr.clone()); + suite_built.register_reward_denom( + self.rewards_config.clone(), + suite_built.staking_addr.to_string().as_ref(), + ); + match &self.rewards_config.denom { + UncheckedDenom::Native(_) => { + suite_built.fund_distributor_native(coin(100_000_000, DENOM.to_string())); + } + UncheckedDenom::Cw20(addr) => { + suite_built.fund_distributor_cw20(Cw20Coin { + address: addr.to_string(), + amount: Uint128::new(100_000_000), + }); + } + }; + } + } + + println!("voting power addr: {}", suite_built.voting_power_addr); + println!("staking addr: {}", suite_built.staking_addr); + suite_built + } +} + +pub struct Suite { + pub app: App, + pub owner: Option, + + pub staking_addr: Addr, + pub voting_power_addr: Addr, + pub reward_denom: String, + + pub distribution_contract: Addr, + + // cw20 type fields + pub cw20_addr: Addr, +} + +// SUITE QUERIES +impl Suite { + pub fn get_time_until_rewards_expiration(&mut self) -> u64 { + let rewards_state_response = self.get_rewards_state_response(); + let current_block = self.app.block_info(); + let (expiration_unit, current_unit) = match rewards_state_response.rewards[0].ends_at { + cw20::Expiration::AtHeight(h) => (h, current_block.height), + cw20::Expiration::AtTime(t) => (t.seconds(), current_block.time.seconds()), + cw20::Expiration::Never {} => return 0, + }; + + if expiration_unit > current_unit { + expiration_unit - current_unit + } else { + 0 + } + } + + pub fn get_balance_native, U: Into>( + &self, + address: T, + denom: U, + ) -> u128 { + self.app + .wrap() + .query_balance(address, denom) + .unwrap() + .amount + .u128() + } + + pub fn get_balance_cw20, U: Into>( + &self, + contract_addr: T, + address: U, + ) -> u128 { + let msg = cw20::Cw20QueryMsg::Balance { + address: address.into(), + }; + let result: cw20::BalanceResponse = self + .app + .wrap() + .query_wasm_smart(contract_addr, &msg) + .unwrap(); + result.balance.u128() + } + + #[allow(dead_code)] + pub fn get_ownership>(&mut self, address: T) -> Ownership { + self.app + .wrap() + .query_wasm_smart(address, &QueryMsg::Ownership {}) + .unwrap() + } + + pub fn get_rewards_state_response(&mut self) -> RewardsStateResponse { + self.app + .wrap() + .query_wasm_smart( + self.distribution_contract.clone(), + &QueryMsg::RewardsState {}, + ) + .unwrap() + } + + pub fn _get_denom_reward_state(&mut self, denom: &str) -> DenomRewardState { + let resp: DenomRewardState = self + .app + .wrap() + .query_wasm_smart( + self.distribution_contract.clone(), + &QueryMsg::DenomRewardState { + denom: denom.to_string(), + }, + ) + .unwrap(); + println!("[{} REWARD STATE] {:?}", denom, resp); + resp + } +} + +// SUITE ASSERTIONS +impl Suite { + pub fn assert_ends_at(&mut self, expected: Expiration) { + let rewards_state_response = self.get_rewards_state_response(); + assert_eq!(rewards_state_response.rewards[0].ends_at, expected); + } + + pub fn assert_started_at(&mut self, expected: Expiration) { + let denom_configs = self.get_rewards_state_response(); + assert_eq!(denom_configs.rewards[0].started_at, expected); + } + + pub fn assert_amount(&mut self, expected: u128) { + let rewards_state_response = self.get_rewards_state_response(); + assert_eq!( + rewards_state_response.rewards[0].emission_rate.amount, + Uint128::new(expected) + ); + } + + pub fn assert_duration(&mut self, expected: u64) { + let rewards_state_response = self.get_rewards_state_response(); + let units = match rewards_state_response.rewards[0].emission_rate.duration { + Duration::Height(h) => h, + Duration::Time(t) => t, + }; + assert_eq!(units, expected); + } + + pub fn get_owner(&mut self) -> Addr { + let ownable_response: cw_ownable::Ownership = self + .app + .borrow_mut() + .wrap() + .query_wasm_smart(self.distribution_contract.clone(), &QueryMsg::Ownership {}) + .unwrap(); + ownable_response.owner.unwrap() + } + + pub fn assert_pending_rewards(&mut self, address: &str, _denom: &str, expected: u128) { + let res: PendingRewardsResponse = self + .app + .borrow_mut() + .wrap() + .query_wasm_smart( + self.distribution_contract.clone(), + &QueryMsg::GetPendingRewards { + address: address.to_string(), + }, + ) + .unwrap(); + + let pending = res.pending_rewards.get(self.reward_denom.as_str()).unwrap(); + + assert_eq!( + pending, + &Uint128::new(expected), + "expected {} pending rewards, got {}", + expected, + pending + ); + } + + pub fn assert_native_balance(&mut self, address: &str, denom: &str, expected: u128) { + let balance = self.get_balance_native(address, denom); + assert_eq!(balance, expected); + } + + pub fn assert_cw20_balance(&mut self, address: &str, expected: u128) { + let balance = self.get_balance_cw20(self.reward_denom.clone(), address); + assert_eq!(balance, expected); + } +} + +// SUITE ACTIONS +impl Suite { + pub fn shutdown_denom_distribution(&mut self, denom: &str) { + let msg = ExecuteMsg::Shutdown { + denom: denom.to_string(), + }; + self.app + .execute_contract( + Addr::unchecked(OWNER), + self.distribution_contract.clone(), + &msg, + &[], + ) + .unwrap(); + } + + pub fn register_hook(&mut self, addr: Addr) { + let msg = cw4_group::msg::ExecuteMsg::AddHook { + addr: self.distribution_contract.to_string(), + }; + // TODO: cw721 check here + self.app + .execute_contract(Addr::unchecked(OWNER), addr, &msg, &[]) + .unwrap(); + } + + pub fn register_reward_denom(&mut self, reward_config: RewardsConfig, hook_caller: &str) { + let register_reward_denom_msg = ExecuteMsg::RegisterRewardDenom { + denom: reward_config.denom.clone(), + emission_rate: RewardEmissionRate { + amount: Uint128::new(reward_config.amount), + duration: reward_config.duration, + }, + hook_caller: hook_caller.to_string(), + vp_contract: self.voting_power_addr.to_string(), + withdraw_destination: reward_config.destination, + }; + + self.app + .borrow_mut() + .execute_contract( + self.owner.clone().unwrap(), + self.distribution_contract.clone(), + ®ister_reward_denom_msg, + &[], + ) + .unwrap(); + } + + pub fn mint_native_coin(&mut self, coin: Coin, dest: &str) { + // mint the tokens to be funded + self.app + .borrow_mut() + .sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: dest.to_string(), + amount: vec![coin.clone()], + } + })) + .unwrap(); + } + + pub fn mint_cw20_coin(&mut self, coin: Cw20Coin, dest: &str, name: &str) -> Addr { + let _msg = cw20::Cw20ExecuteMsg::Mint { + recipient: dest.to_string(), + amount: coin.amount, + }; + cw20_setup::instantiate_cw20(self.app.borrow_mut(), name, vec![coin]) + } + + pub fn fund_distributor_native(&mut self, coin: Coin) { + self.mint_native_coin(coin.clone(), OWNER); + println!("[FUNDING EVENT] native funding: {}", coin); + self.app + .borrow_mut() + .execute_contract( + Addr::unchecked(OWNER), + self.distribution_contract.clone(), + &ExecuteMsg::Fund {}, + &[coin], + ) + .unwrap(); + } + + pub fn fund_distributor_cw20(&mut self, coin: Cw20Coin) { + println!("[FUNDING EVENT] cw20 funding: {}", coin); + + let fund_sub_msg = to_json_binary(&ReceiveMsg::Fund {}).unwrap(); + self.app + .execute_contract( + Addr::unchecked(OWNER), + Addr::unchecked(coin.address), + &cw20::Cw20ExecuteMsg::Send { + contract: self.distribution_contract.to_string(), + amount: coin.amount, + msg: fund_sub_msg, + }, + &[], + ) + .unwrap(); + } + + pub fn skip_blocks(&mut self, blocks: u64) { + self.app.borrow_mut().update_block(|b| { + println!("skipping blocks {:?} -> {:?}", b.height, b.height + blocks); + b.height += blocks + }); + } + + pub fn skip_seconds(&mut self, seconds: u64) { + self.app.borrow_mut().update_block(|b| { + let new_block_time = b.time.plus_seconds(seconds); + println!( + "skipping seconds {:?} -> {:?}", + b.time.seconds(), + new_block_time.seconds() + ); + b.time = new_block_time; + // this is needed because voting power query only exists based on height. + // for time-based unit tests we assume that 1 block = 1 second. + // only implication I can think of is that during mainnet network downtime, + // rewards would continue to accrue for time-based distributions, whereas + // height-based distributions would not. + b.height += seconds; + }); + } + + pub fn claim_rewards(&mut self, address: &str, denom: &str) { + let msg = ExecuteMsg::Claim { + denom: denom.to_string(), + }; + + self.app + .execute_contract( + Addr::unchecked(address), + self.distribution_contract.clone(), + &msg, + &[], + ) + .unwrap(); + } + + #[allow(dead_code)] + pub fn stake_cw20_tokens(&mut self, amount: u128, sender: &str) { + let msg = cw20::Cw20ExecuteMsg::Send { + contract: self.staking_addr.to_string(), + amount: Uint128::new(amount), + msg: to_json_binary(&cw20_stake::msg::ReceiveMsg::Stake {}).unwrap(), + }; + println!("[STAKING EVENT] {} staked {}", sender, amount); + self.app + .execute_contract(Addr::unchecked(sender), self.cw20_addr.clone(), &msg, &[]) + .unwrap(); + } + + pub fn unstake_cw20_tokens(&mut self, amount: u128, sender: &str) { + let msg = cw20_stake::msg::ExecuteMsg::Unstake { + amount: Uint128::new(amount), + }; + println!("[STAKING EVENT] {} unstaked {}", sender, amount); + self.app + .execute_contract( + Addr::unchecked(sender), + self.staking_addr.clone(), + &msg, + &[], + ) + .unwrap(); + } + + pub fn stake_nft(&mut self, sender: &str, token_id: u64) { + stake_cw721( + self.app.borrow_mut(), + &self.voting_power_addr, + &self.staking_addr, + sender, + &token_id.to_string(), + ) + } + + pub fn unstake_nft(&mut self, sender: &str, token_id: u64) { + unstake_cw721( + self.app.borrow_mut(), + &self.voting_power_addr, + sender, + &token_id.to_string(), + ) + } + + pub fn stake_native_tokens(&mut self, address: &str, amount: u128) { + stake_tokenfactory_tokens(self.app.borrow_mut(), &self.staking_addr, address, amount) + } + + pub fn unstake_native_tokens(&mut self, address: &str, amount: u128) { + unstake_tokenfactory_tokens(self.app.borrow_mut(), &self.staking_addr, address, amount) + } + + pub fn update_members(&mut self, add: Vec, remove: Vec) { + let msg = cw4_group::msg::ExecuteMsg::UpdateMembers { remove, add }; + + self.app + .execute_contract(Addr::unchecked(OWNER), self.staking_addr.clone(), &msg, &[]) + .unwrap(); + } + + pub fn query_members(&mut self) -> Vec { + let members: MemberListResponse = self + .app + .wrap() + .query_wasm_smart( + self.staking_addr.clone(), + &cw4_group::msg::QueryMsg::ListMembers { + start_after: None, + limit: None, + }, + ) + .unwrap(); + println!("[UPDATE CW4] new members: {:?}", members); + members.members + } + + pub fn update_owner(&mut self, new_owner: &str) { + let msg = ExecuteMsg::UpdateOwnership(Action::TransferOwnership { + new_owner: new_owner.to_string(), + expiry: None, + }); + + self.app + .execute_contract( + Addr::unchecked(OWNER), + self.distribution_contract.clone(), + &msg, + &[], + ) + .unwrap(); + + self.app + .execute_contract( + Addr::unchecked(new_owner), + self.distribution_contract.clone(), + &ExecuteMsg::UpdateOwnership(Action::AcceptOwnership {}), + &[], + ) + .unwrap(); + } +} diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs new file mode 100644 index 000000000..764a04d3a --- /dev/null +++ b/contracts/distribution/dao-rewards-distributor/src/testing/tests.rs @@ -0,0 +1,1143 @@ +use std::borrow::BorrowMut; + +use cosmwasm_std::Uint128; +use cosmwasm_std::{coin, to_json_binary, Addr, Timestamp}; +use cw20::{Cw20Coin, Expiration, UncheckedDenom}; +use cw4::Member; +use cw_multi_test::Executor; +use cw_utils::Duration; + +use crate::{ + msg::ExecuteMsg, + testing::{ADDR1, ADDR2, ADDR3, DENOM}, +}; + +use super::{ + suite::{RewardsConfig, SuiteBuilder}, + ALT_DENOM, OWNER, +}; + +// By default, the tests are set up to distribute rewards over 1_000_000 units of time. +// Over that time, 100_000_000 token rewards will be distributed. + +#[test] +fn test_cw20_dao_native_rewards_block_height_based() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20).build(); + + suite.assert_amount(1_000); + suite.assert_ends_at(Expiration::AtHeight(1_000_000)); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_native_balance(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + + // ADDR2 and ADDR3 unstake their rewards + suite.unstake_cw20_tokens(50, ADDR2); + suite.unstake_cw20_tokens(50, ADDR3); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // because ADDR2 and ADDR3 are not staking, ADDR1 receives all the rewards. + // ADDR2 and ADDR3 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR2 and ADDR3 wake up, claim and restake their rewards + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + suite.stake_cw20_tokens(50, ADDR2); + + // skip 3/10th of the time + suite.skip_blocks(300_000); + + suite.stake_cw20_tokens(50, ADDR3); + + suite.assert_pending_rewards(ADDR1, DENOM, 30_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 0); + + suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR2, DENOM); + + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 0); + + let remaining_time = suite.get_time_until_rewards_expiration(); + + suite.skip_blocks(remaining_time - 100_000); + + suite.claim_rewards(ADDR1, DENOM); + suite.unstake_cw20_tokens(100, ADDR1); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + + suite.skip_blocks(100_000); + + suite.unstake_cw20_tokens(50, ADDR2); + suite.skip_blocks(100_000); + + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 0); + + let addr1_bal = suite.get_balance_native(ADDR1, DENOM); + let addr2_bal = suite.get_balance_native(ADDR2, DENOM); + let addr3_bal = suite.get_balance_native(ADDR3, DENOM); + + println!("Balances: {}, {}, {}", addr1_bal, addr2_bal, addr3_bal); +} + +#[test] +fn test_cw721_dao_rewards() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW721).build(); + + suite.assert_amount(1_000); + suite.assert_ends_at(Expiration::AtHeight(1_000_000)); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_native_balance(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + + // ADDR2 and ADDR3 unstake their nfts + suite.unstake_nft(ADDR2, 3); + suite.unstake_nft(ADDR3, 4); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // because ADDR2 and ADDR3 are not staking, ADDR1 receives all the rewards. + // ADDR2 and ADDR3 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR2 and ADDR3 wake up, claim and restake their nfts + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + suite.stake_nft(ADDR2, 3); + suite.stake_nft(ADDR3, 4); +} + +#[test] +#[should_panic(expected = "No rewards claimable")] +fn test_claim_zero_rewards() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20).build(); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, DENOM); + + // ADDR1 attempts to claim again + suite.claim_rewards(ADDR1, DENOM); +} + +#[test] +fn test_native_dao_cw20_rewards_time_based() { + // 1000udenom/10sec = 100udenom/1sec reward emission rate + // given funding of 100_000_000udenom, we have a reward duration of 1_000_000sec + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20) + .with_rewards_config(RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Cw20(DENOM.to_string()), + duration: Duration::Time(10), + destination: None, + }) + .build(); + + suite.assert_amount(1_000); + suite.assert_duration(10); + suite.assert_ends_at(Expiration::AtTime(Timestamp::from_seconds(1_000_000))); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + // suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, suite.reward_denom.clone().as_str()); + suite.assert_cw20_balance(ADDR1, 10_000_000); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + + // ADDR2 and ADDR3 unstake their stake + suite.unstake_cw20_tokens(50, ADDR2); + suite.unstake_cw20_tokens(50, ADDR3); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + // because ADDR2 and ADDR3 are not staking, ADDR1 receives all the rewards. + // ADDR2 and ADDR3 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR2 and ADDR3 wake up and claim their rewards + suite.claim_rewards(ADDR2, suite.reward_denom.clone().as_str()); + suite.claim_rewards(ADDR3, suite.reward_denom.clone().as_str()); + + suite.assert_cw20_balance(ADDR1, 10_000_000); + suite.assert_cw20_balance(ADDR2, 5_000_000); +} + +#[test] +fn test_native_dao_rewards_time_based() { + // 1000udenom/10sec = 100udenom/1sec reward emission rate + // given funding of 100_000_000udenom, we have a reward duration of 1_000_000sec + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native) + .with_rewards_config(RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Native(DENOM.to_string()), + duration: Duration::Time(10), + destination: None, + }) + .build(); + + suite.assert_amount(1_000); + suite.assert_duration(10); + suite.assert_ends_at(Expiration::AtTime(Timestamp::from_seconds(1_000_000))); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + // suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_native_balance(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + + // ADDR2 and ADDR3 unstake their stake + suite.unstake_native_tokens(ADDR2, 50); + suite.unstake_native_tokens(ADDR3, 50); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + // because ADDR2 and ADDR3 are not staking, ADDR1 receives all the rewards. + // ADDR2 and ADDR3 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR2 and ADDR3 wake up, claim and restake their rewards + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + let addr1_balance = suite.get_balance_native(ADDR1, DENOM); + let addr2_balance = suite.get_balance_native(ADDR2, DENOM); + + suite.stake_native_tokens(ADDR1, addr1_balance); + suite.stake_native_tokens(ADDR2, addr2_balance); +} + +#[test] +fn test_native_dao_rewards() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + suite.assert_amount(1_000); + suite.assert_ends_at(Expiration::AtHeight(1_000_000)); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_native_balance(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + + // ADDR2 and ADDR3 unstake their stake + suite.unstake_native_tokens(ADDR2, 50); + suite.unstake_native_tokens(ADDR3, 50); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // because ADDR2 and ADDR3 are not staking, ADDR1 receives all the rewards. + // ADDR2 and ADDR3 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_000_000); + + // ADDR2 and ADDR3 wake up, claim and restake their rewards + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + let addr1_balance = suite.get_balance_native(ADDR1, DENOM); + let addr2_balance = suite.get_balance_native(ADDR2, DENOM); + + suite.stake_native_tokens(ADDR1, addr1_balance); + suite.stake_native_tokens(ADDR2, addr2_balance); +} + +#[test] +fn test_cw4_dao_rewards() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW4).build(); + + suite.assert_amount(1_000); + suite.assert_ends_at(Expiration::AtHeight(1_000_000)); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // remove the second member + suite.update_members(vec![], vec![ADDR2.to_string()]); + suite.query_members(); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // now that ADDR2 is no longer a member, ADDR1 and ADDR3 will split the rewards + suite.assert_pending_rewards(ADDR1, DENOM, 11_666_666); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_833_333); + + // reintroduce the 2nd member with double the vp + let add_member_2 = Member { + addr: ADDR2.to_string(), + weight: 2, + }; + suite.update_members(vec![add_member_2], vec![]); + suite.query_members(); + + // now the vp split is [ADDR1: 40%, ADDR2: 40%, ADDR3: 20%] + // meaning the token reward per 100k blocks is 4mil, 4mil, 2mil + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_native_balance(ADDR1, DENOM, 11_666_666); + + // assert pending rewards are still the same (other than ADDR1) + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 5_833_333); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 4_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 6_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 7_833_333); + + // skip 1/2 of time, leaving 200k blocks left + suite.skip_blocks(500_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 24_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 26_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 17_833_333); + + // remove all members + suite.update_members( + vec![], + vec![ADDR1.to_string(), ADDR2.to_string(), ADDR3.to_string()], + ); + + suite.assert_pending_rewards(ADDR1, DENOM, 24_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 26_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 17_833_333); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 24_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 26_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 17_833_333); + + suite.update_members( + vec![ + Member { + addr: ADDR1.to_string(), + weight: 2, + }, + Member { + addr: ADDR2.to_string(), + weight: 2, + }, + Member { + addr: ADDR3.to_string(), + weight: 1, + }, + ], + vec![], + ); + + suite.assert_pending_rewards(ADDR1, DENOM, 24_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 26_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 17_833_333); + + suite.claim_rewards(ADDR1, DENOM); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_native_balance(ADDR1, DENOM, 35_666_666); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 4_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 30_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 19_833_333); + + // at the very expiration block, claim rewards + suite.claim_rewards(ADDR2, DENOM); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_native_balance(ADDR2, DENOM, 30_500_000); + + suite.skip_blocks(100_000); + + suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 0); + + let contract = suite.distribution_contract.clone(); + + // for 100k blocks there were no members so some rewards are remaining in the contract. + let contract_token_balance = suite.get_balance_native(contract.clone(), DENOM); + assert!(contract_token_balance > 0); +} + +#[test] +#[should_panic(expected = "Invalid funds")] +fn test_fund_multiple_denoms() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let alt_coin = coin(100_000_000, ALT_DENOM); + let coin = coin(100_000_000, DENOM); + suite.mint_native_coin(alt_coin.clone(), OWNER); + suite.mint_native_coin(coin.clone(), OWNER); + let hook_caller = suite.staking_addr.to_string(); + suite.register_reward_denom( + RewardsConfig { + amount: 1000, + denom: cw20::UncheckedDenom::Native(ALT_DENOM.to_string()), + duration: Duration::Height(100), + destination: None, + }, + &hook_caller, + ); + + suite + .app + .borrow_mut() + .execute_contract( + Addr::unchecked(OWNER), + suite.distribution_contract.clone(), + &ExecuteMsg::Fund {}, + &[coin, alt_coin], + ) + .unwrap(); +} + +#[test] +#[should_panic(expected = "unknown variant `not_the_fund: {}`")] +fn test_fund_cw20_with_invalid_cw20_receive_msg() { + // attempting to fund a non-registered cw20 token should error + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20).build(); + + let unregistered_cw20_coin = Cw20Coin { + address: ADDR1.to_string(), + amount: Uint128::new(1_000_000), + }; + + let new_cw20_mint = suite.mint_cw20_coin(unregistered_cw20_coin.clone(), ADDR1, "newcoin"); + println!("[FUNDING EVENT] cw20 funding: {}", unregistered_cw20_coin); + + let fund_sub_msg = to_json_binary(&"not_the_fund: {}").unwrap(); + suite + .app + .execute_contract( + Addr::unchecked(ADDR1), + new_cw20_mint.clone(), + &cw20::Cw20ExecuteMsg::Send { + contract: suite.distribution_contract.to_string(), + amount: unregistered_cw20_coin.amount, + msg: fund_sub_msg, + }, + &[], + ) + .unwrap(); +} + +#[test] +#[should_panic] +fn test_fund_invalid_cw20_denom() { + // attempting to fund a non-registered cw20 token should error + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20).build(); + + let unregistered_cw20_coin = Cw20Coin { + address: ADDR1.to_string(), + amount: Uint128::new(1_000_000), + }; + + println!("attempting to fund the distributor contract with unregistered cw20 coin"); + suite.fund_distributor_cw20(unregistered_cw20_coin); +} + +#[test] +#[should_panic(expected = "Reward period already finished")] +fn test_shutdown_finished_rewards_period() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + // skip to expiration + suite.skip_blocks(2_000_000); + + suite.shutdown_denom_distribution(DENOM); +} + +#[test] +fn test_shutdown_alternative_destination_address() { + let subdao_addr = "some_subdao_maybe".to_string(); + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native) + .with_withdraw_destination(Some(subdao_addr.to_string())) + .build(); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // user 1 and 2 claim their rewards + suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR2, DENOM); + + // user 2 unstakes + suite.unstake_native_tokens(ADDR2, 50); + + suite.skip_blocks(100_000); + + let distribution_contract = suite.distribution_contract.to_string(); + + suite.assert_native_balance(subdao_addr.as_str(), DENOM, 0); + let pre_shutdown_distributor_balance = + suite.get_balance_native(distribution_contract.clone(), DENOM); + + suite.shutdown_denom_distribution(DENOM); + + let post_shutdown_distributor_balance = + suite.get_balance_native(distribution_contract.clone(), DENOM); + let post_shutdown_subdao_balance = suite.get_balance_native(subdao_addr.to_string(), DENOM); + + // after shutdown the balance of the subdao should be the same + // as pre-shutdown-distributor-bal minus post-shutdown-distributor-bal + assert_eq!( + pre_shutdown_distributor_balance - post_shutdown_distributor_balance, + post_shutdown_subdao_balance + ); +} + +#[test] +fn test_shutdown_block_based() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // user 1 and 2 claim their rewards + suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR2, DENOM); + + // user 2 unstakes + suite.unstake_native_tokens(ADDR2, 50); + + suite.skip_blocks(100_000); + + let distribution_contract = suite.distribution_contract.to_string(); + + let pre_shutdown_distributor_balance = + suite.get_balance_native(distribution_contract.clone(), DENOM); + + suite.assert_native_balance(suite.owner.clone().unwrap().as_str(), DENOM, 0); + suite.shutdown_denom_distribution(DENOM); + + let post_shutdown_distributor_balance = + suite.get_balance_native(distribution_contract.clone(), DENOM); + let post_shutdown_owner_balance = suite.get_balance_native(suite.owner.clone().unwrap(), DENOM); + + // after shutdown the balance of the owner should be the same + // as pre-shutdown-distributor-bal minus post-shutdown-distributor-bal + assert_eq!( + pre_shutdown_distributor_balance - post_shutdown_distributor_balance, + post_shutdown_owner_balance + ); + + suite.skip_blocks(100_000); + + // we assert that pending rewards did not change + suite.assert_pending_rewards(ADDR1, DENOM, 6_666_666); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 5_833_333); + + // user 1 can claim their rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_native_balance(ADDR1, DENOM, 11_666_666); + + // user 3 can unstake and claim their rewards + suite.unstake_native_tokens(ADDR3, 50); + suite.skip_blocks(100_000); + suite.assert_native_balance(ADDR3, DENOM, 50); + suite.claim_rewards(ADDR3, DENOM); + suite.assert_pending_rewards(ADDR3, DENOM, 0); + suite.assert_native_balance(ADDR3, DENOM, 5_833_333 + 50); + + // TODO: fix this rug of 1 udenom by the distribution contract + suite.assert_native_balance(&distribution_contract, DENOM, 1); +} + +#[test] +fn test_shutdown_time_based() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native) + .with_rewards_config(RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Native(DENOM.to_string()), + duration: Duration::Time(10), + destination: None, + }) + .build(); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // user 1 and 2 claim their rewards + suite.claim_rewards(ADDR1, DENOM); + suite.claim_rewards(ADDR2, DENOM); + + // user 2 unstakes + suite.unstake_native_tokens(ADDR2, 50); + + suite.skip_seconds(100_000); + + let distribution_contract = suite.distribution_contract.to_string(); + + let pre_shutdown_distributor_balance = + suite.get_balance_native(distribution_contract.clone(), DENOM); + + suite.assert_native_balance(suite.owner.clone().unwrap().as_str(), DENOM, 0); + suite.shutdown_denom_distribution(DENOM); + + let post_shutdown_distributor_balance = + suite.get_balance_native(distribution_contract.clone(), DENOM); + let post_shutdown_owner_balance = suite.get_balance_native(suite.owner.clone().unwrap(), DENOM); + + // after shutdown the balance of the owner should be the same + // as pre-shutdown-distributor-bal minus post-shutdown-distributor-bal + assert_eq!( + pre_shutdown_distributor_balance - post_shutdown_distributor_balance, + post_shutdown_owner_balance + ); + + suite.skip_seconds(100_000); + + // we assert that pending rewards did not change + suite.assert_pending_rewards(ADDR1, DENOM, 6_666_666); + suite.assert_pending_rewards(ADDR2, DENOM, 0); + suite.assert_pending_rewards(ADDR3, DENOM, 5_833_333); + + // user 1 can claim their rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + suite.assert_native_balance(ADDR1, DENOM, 11_666_666); + + // user 3 can unstake and claim their rewards + suite.unstake_native_tokens(ADDR3, 50); + suite.skip_seconds(100_000); + suite.assert_native_balance(ADDR3, DENOM, 50); + suite.claim_rewards(ADDR3, DENOM); + suite.assert_pending_rewards(ADDR3, DENOM, 0); + suite.assert_native_balance(ADDR3, DENOM, 5_833_333 + 50); + + // TODO: fix this rug of 1 udenom by the distribution contract + suite.assert_native_balance(&distribution_contract, DENOM, 1); +} + +#[test] +#[should_panic(expected = "Caller is not the contract's current owner")] +fn test_shudown_unauthorized() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite + .app + .borrow_mut() + .execute_contract( + Addr::unchecked(ADDR1), + suite.distribution_contract.clone(), + &ExecuteMsg::Shutdown { + denom: DENOM.to_string(), + }, + &[], + ) + .unwrap(); +} + +#[test] +#[should_panic] +fn test_shutdown_unregistered_denom() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + suite.skip_blocks(100_000); + + suite.shutdown_denom_distribution("not-the-denom"); +} + +#[test] +#[should_panic(expected = "Denom already registered")] +fn test_register_duplicate_denom() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let hook_caller = suite.staking_addr.to_string(); + let reward_config = RewardsConfig { + amount: 1000, + denom: cw20::UncheckedDenom::Native(DENOM.to_string()), + duration: Duration::Height(100), + destination: None, + }; + suite.register_reward_denom(reward_config, &hook_caller); +} + +#[test] +#[should_panic] +fn test_fund_invalid_native_denom() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.mint_native_coin(coin(100_000_000, ALT_DENOM), OWNER); + suite + .app + .borrow_mut() + .execute_contract( + Addr::unchecked(OWNER), + suite.distribution_contract.clone(), + &ExecuteMsg::Fund {}, + &[coin(100_000_000, ALT_DENOM)], + ) + .unwrap(); +} + +#[test] +fn test_fund_unauthorized() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.mint_native_coin(coin(100_000_000, DENOM), ADDR1); + suite + .app + .borrow_mut() + .execute_contract( + Addr::unchecked(ADDR1), + suite.distribution_contract.clone(), + &ExecuteMsg::Fund {}, + &[coin(100_000_000, DENOM)], + ) + .unwrap(); +} + +#[test] +fn test_fund_native_block_based_post_expiration() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let started_at = Expiration::AtHeight(0); + let funded_blocks = 1_000_000; + let expiration_date = Expiration::AtHeight(funded_blocks); + suite.assert_amount(1_000); + suite.assert_ends_at(expiration_date); + suite.assert_started_at(started_at); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // ADDR2 unstake their stake + suite.unstake_native_tokens(ADDR2, 50); + + // addr3 claims their rewards + suite.claim_rewards(ADDR3, DENOM); + + // skip to 100_000 blocks past the expiration + suite.skip_blocks(1_000_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 65_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 30_000_000); + + suite.assert_ends_at(expiration_date); + suite.assert_started_at(started_at); + + // we fund the distributor with the same amount of coins as + // during setup, meaning that the rewards distribution duration + // should be the same. + suite.fund_distributor_native(coin(100_000_000, DENOM)); + + let current_block = suite.app.block_info(); + + // funding after the reward period had expired should + // reset the start date to that of the funding. + suite.assert_started_at(Expiration::AtHeight(current_block.height)); + + // funding after the reward period had expired should + // set the distribution expiration to the funded duration + // after current block + suite.assert_ends_at(Expiration::AtHeight(current_block.height + funded_blocks)); +} + +#[test] +fn test_fund_cw20_time_based_post_expiration() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20) + .with_rewards_config(RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Cw20(DENOM.to_string()), + duration: Duration::Time(10), + destination: None, + }) + .build(); + + let started_at = Expiration::AtTime(Timestamp::from_seconds(0)); + let funded_timestamp = Timestamp::from_seconds(1_000_000); + let expiration_date = Expiration::AtTime(funded_timestamp); + suite.assert_amount(1_000); + suite.assert_ends_at(expiration_date); + suite.assert_started_at(started_at); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // ADDR2 unstake their stake + suite.unstake_cw20_tokens(50, ADDR2); + + // addr3 claims their rewards + suite.claim_rewards(ADDR3, suite.reward_denom.clone().as_str()); + suite.assert_cw20_balance(ADDR3, 2_500_000); + + // skip to 100_000 blocks past the expiration + suite.skip_seconds(1_000_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 65_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 30_000_000); + + suite.assert_ends_at(expiration_date); + suite.assert_started_at(started_at); + + // we fund the distributor with the same amount of coins as + // during setup, meaning that the rewards distribution duration + // should be the same. + let funding_denom = Cw20Coin { + address: suite.reward_denom.to_string(), + amount: Uint128::new(100_000_000), + }; + + suite.fund_distributor_cw20(funding_denom.clone()); + + let current_block = suite.app.block_info(); + + // funding after the reward period had expired should + // reset the start date to that of the funding. + suite.assert_started_at(Expiration::AtTime(current_block.time)); + + // funding after the reward period had expired should + // set the distribution expiration to the funded duration + // after current block + suite.assert_ends_at(Expiration::AtTime( + current_block.time.plus_seconds(funded_timestamp.seconds()), + )); +} + +#[test] +fn test_fund_cw20_time_based_pre_expiration() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::CW20) + .with_rewards_config(RewardsConfig { + amount: 1_000, + denom: UncheckedDenom::Cw20(DENOM.to_string()), + duration: Duration::Time(10), + destination: None, + }) + .build(); + + let started_at = Expiration::AtTime(Timestamp::from_seconds(0)); + let funded_timestamp = Timestamp::from_seconds(1_000_000); + let expiration_date = Expiration::AtTime(funded_timestamp); + suite.assert_amount(1_000); + suite.assert_ends_at(expiration_date); + suite.assert_started_at(started_at); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_seconds(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // ADDR2 unstake their stake + suite.unstake_cw20_tokens(50, ADDR2); + + // addr3 claims their rewards + suite.claim_rewards(ADDR3, suite.reward_denom.clone().as_str()); + + // skip to 100_000 blocks before the expiration + suite.skip_seconds(800_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 58_333_333); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 26_666_666); + + suite.assert_ends_at(expiration_date); + suite.assert_started_at(started_at); + + // we fund the distributor with the same amount of coins as + // during setup, meaning that the rewards distribution duration + // should be the same. + let funding_denom = Cw20Coin { + address: suite.reward_denom.to_string(), + amount: Uint128::new(100_000_000), + }; + suite.fund_distributor_cw20(funding_denom.clone()); + + // funding before the reward period expires should + // not reset the existing rewards cycle + suite.assert_started_at(started_at); + + // funding before the reward period expires should + // extend the current distribution expiration by the + // newly funded duration + suite.assert_ends_at(Expiration::AtTime(Timestamp::from_seconds( + funded_timestamp.seconds() * 2, + ))); +} + +#[test] +fn test_fund_native_height_based_pre_expiration() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let started_at = Expiration::AtHeight(0); + let funded_blocks = 1_000_000; + let expiration_date = Expiration::AtHeight(funded_blocks); + suite.assert_amount(1_000); + suite.assert_ends_at(expiration_date); + suite.assert_started_at(started_at); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // ADDR2 unstake their stake + suite.unstake_native_tokens(ADDR2, 50); + + // addr3 claims their rewards + suite.claim_rewards(ADDR3, DENOM); + + // skip to 100_000 blocks before the expiration + suite.skip_blocks(800_000); + + suite.assert_pending_rewards(ADDR1, DENOM, 58_333_333); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 26_666_666); + + suite.assert_ends_at(expiration_date); + suite.assert_started_at(started_at); + + // we fund the distributor with the same amount of coins as + // during setup, meaning that the rewards distribution duration + // should be the same. + suite.fund_distributor_native(coin(100_000_000, DENOM)); + + // funding before the reward period expires should + // not reset the existing rewards cycle + suite.assert_started_at(started_at); + + // funding before the reward period expires should + // extend the current distribution expiration by the + // newly funded duration + suite.assert_ends_at(Expiration::AtHeight(funded_blocks * 2)); +} + +#[test] +fn test_native_dao_rewards_entry_edge_case() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + // we start with the following staking power split: + // [ADDR1: 100, ADDR2: 50, ADDR3: 50], or [ADDR1: 50%, ADDR2: 25%, ADDR3: 25% + suite.assert_amount(1_000); + suite.assert_ends_at(Expiration::AtHeight(1_000_000)); + suite.assert_duration(10); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // ADDR1 stakes additional 100 tokens, bringing the new staking power split to + // [ADDR1: 200, ADDR2: 50, ADDR3: 50], or [ADDR1: 66.6%, ADDR2: 16.6%, ADDR3: 16.6%] + // this means that per 100_000 blocks, ADDR1 should receive 6_666_666, while + // ADDR2 and ADDR3 should receive 1_666_666 each. + suite.mint_native_coin(coin(100, DENOM), ADDR1); + println!("staking native coins\n"); + suite.stake_native_tokens(ADDR1, 100); + + // rewards here should not be affected by the new stake, + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000); + + // skip 1/10th of the time + suite.skip_blocks(100_000); + + // here we should see the new stake affecting the rewards split. + suite.assert_pending_rewards(ADDR1, DENOM, 5_000_000 + 6_666_666); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000 + 1_666_666); + + // ADDR1 claims rewards + suite.claim_rewards(ADDR1, DENOM); + suite.assert_native_balance(ADDR1, DENOM, 5_000_000 + 6_666_666); + suite.assert_pending_rewards(ADDR1, DENOM, 0); + + // ADDR2 and ADDR3 unstake their stake + // new voting power split is [ADDR1: 100%, ADDR2: 0%, ADDR3: 0%] + suite.unstake_native_tokens(ADDR2, 50); + suite.unstake_native_tokens(ADDR3, 50); + + // we assert that by unstaking, ADDR2 and ADDR3 do not forfeit their earned but unclaimed rewards + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000 + 1_666_666); + + // skip a block and assert that nothing changes + suite.skip_blocks(1); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000 + 1_666_666); + + // skip the remaining blocks to reach 1/10th of the time + suite.skip_blocks(99_999); + + // because ADDR2 and ADDR3 are not staking, ADDR1 receives all the rewards. + // ADDR2 and ADDR3 should have the same amount of pending rewards as before. + suite.assert_pending_rewards(ADDR1, DENOM, 10_000_000); + suite.assert_pending_rewards(ADDR2, DENOM, 2_500_000 + 1_666_666); + suite.assert_pending_rewards(ADDR3, DENOM, 2_500_000 + 1_666_666); + + // ADDR2 and ADDR3 wake up, claim and restake their rewards + suite.claim_rewards(ADDR2, DENOM); + suite.claim_rewards(ADDR3, DENOM); + + let addr1_balance = suite.get_balance_native(ADDR1, DENOM); + let addr2_balance = suite.get_balance_native(ADDR2, DENOM); + + suite.stake_native_tokens(ADDR1, addr1_balance); + suite.stake_native_tokens(ADDR2, addr2_balance); +} + +#[test] +fn test_update_owner() { + let mut suite = SuiteBuilder::base(super::suite::DaoType::Native).build(); + + let new_owner = "new_owner"; + suite.update_owner(new_owner); + + let owner = suite.get_owner().to_string(); + assert_eq!(owner, new_owner); +} diff --git a/contracts/proposal/dao-proposal-multiple/src/error.rs b/contracts/proposal/dao-proposal-multiple/src/error.rs index 76fe05724..433103f7d 100644 --- a/contracts/proposal/dao-proposal-multiple/src/error.rs +++ b/contracts/proposal/dao-proposal-multiple/src/error.rs @@ -1,5 +1,3 @@ -use std::u64; - use cosmwasm_std::StdError; use cw_hooks::HookError; use cw_utils::ParseReplyError; 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..51a0747b8 100644 --- a/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs +++ b/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs @@ -392,7 +392,7 @@ where vec![TestMultipleChoiceVote { voter: "bluenote".to_string(), position: MultipleChoiceVote { option_id: 0 }, - weight: Uint128::new(u128::max_value()), + weight: Uint128::new(u128::MAX), should_execute: ShouldExecute::Yes, }], VotingStrategy::SingleChoice { @@ -414,7 +414,7 @@ where TestMultipleChoiceVote { voter: "bob".to_string(), position: MultipleChoiceVote { option_id: 1 }, - weight: Uint128::new(u128::max_value() - 1), + weight: Uint128::new(u128::MAX - 1), should_execute: ShouldExecute::Yes, }, ], @@ -463,7 +463,7 @@ where vec![TestMultipleChoiceVote { voter: "bluenote".to_string(), position: MultipleChoiceVote { option_id: 2 }, // the last index is none of the above - weight: Uint128::new(u64::max_value().into()), + weight: Uint128::new(u64::MAX.into()), should_execute: ShouldExecute::Yes, }], VotingStrategy::SingleChoice { @@ -479,7 +479,7 @@ where vec![TestMultipleChoiceVote { voter: "bluenote".to_string(), position: MultipleChoiceVote { option_id: 2 }, - weight: Uint128::new(u64::max_value().into()), + weight: Uint128::new(u64::MAX.into()), should_execute: ShouldExecute::Yes, }], VotingStrategy::SingleChoice { diff --git a/contracts/proposal/dao-proposal-single/src/error.rs b/contracts/proposal/dao-proposal-single/src/error.rs index 9fc049d21..c52fee31b 100644 --- a/contracts/proposal/dao-proposal-single/src/error.rs +++ b/contracts/proposal/dao-proposal-single/src/error.rs @@ -1,5 +1,3 @@ -use std::u64; - use cosmwasm_std::StdError; use cw_hooks::HookError; use cw_utils::ParseReplyError; diff --git a/packages/dao-testing/src/tests.rs b/packages/dao-testing/src/tests.rs index d57377333..874278bad 100644 --- a/packages/dao-testing/src/tests.rs +++ b/packages/dao-testing/src/tests.rs @@ -67,7 +67,7 @@ where vec![TestSingleChoiceVote { voter: "ekez".to_string(), position: Vote::Yes, - weight: Uint128::new(u128::max_value()), + weight: Uint128::new(u128::MAX), should_execute: ShouldExecute::Yes, }], Threshold::AbsolutePercentage { @@ -86,7 +86,7 @@ where vec![TestSingleChoiceVote { voter: "ekez".to_string(), position: Vote::Yes, - weight: Uint128::new(u128::max_value()), + weight: Uint128::new(u128::MAX), should_execute: ShouldExecute::Yes, }], Threshold::AbsolutePercentage { @@ -107,7 +107,7 @@ where TestSingleChoiceVote { voter: "ekez".to_string(), position: Vote::Yes, - weight: Uint128::new(u128::max_value() - 1), + weight: Uint128::new(u128::MAX - 1), should_execute: ShouldExecute::Yes, }, ], @@ -148,7 +148,7 @@ where percentage: PercentageThreshold::Percent(Decimal::percent(99)), }, Status::Open, - Some(Uint128::from(u128::max_value())), + Some(Uint128::from(u128::MAX)), ); } @@ -160,7 +160,7 @@ where vec![TestSingleChoiceVote { voter: "ekez".to_string(), position: Vote::Abstain, - weight: Uint128::new(u64::max_value().into()), + weight: Uint128::new(u64::MAX.into()), should_execute: ShouldExecute::Yes, }], Threshold::AbsolutePercentage { @@ -177,7 +177,7 @@ where vec![TestSingleChoiceVote { voter: "ekez".to_string(), position: Vote::Abstain, - weight: Uint128::new(u64::max_value().into()), + weight: Uint128::new(u64::MAX.into()), should_execute: ShouldExecute::Yes, }], Threshold::ThresholdQuorum { diff --git a/scripts/schema.sh b/scripts/schema.sh index 08fa7373e..1dec7ff02 100755 --- a/scripts/schema.sh +++ b/scripts/schema.sh @@ -13,6 +13,16 @@ cargo run --example schema > /dev/null rm -rf ./schema/raw cd "$START_DIR" +for f in ./contracts/distribution/* +do + echo "generating schema for ${f##*/}" + cd "$f" + CMD="cargo run --example schema" + eval $CMD > /dev/null + rm -rf ./schema/raw + cd "$START_DIR" +done + for f in ./contracts/voting/* do echo "generating schema for ${f##*/}" From ac249f418afd94050bb3ba22d429125b6695b02e Mon Sep 17 00:00:00 2001 From: noah Date: Sat, 6 Jul 2024 23:11:22 -0400 Subject: [PATCH 4/5] Improve pre-propose proposal creation permission granularity (and bump versions to v2.5.0) (#843) --- Cargo.lock | 158 ++-- Cargo.toml | 80 +- ci/bootstrap-env/src/main.rs | 7 +- ci/integration-tests/src/helpers/helper.rs | 8 +- .../dao-dao-core/schema/dao-dao-core.json | 2 +- .../schema/cw-fund-distributor.json | 2 +- .../schema/dao-rewards-distributor.json | 2 +- .../schema/cw-admin-factory.json | 2 +- .../schema/cw-payroll-factory.json | 2 +- .../cw-token-swap/schema/cw-token-swap.json | 2 +- .../schema/cw-tokenfactory-issuer.json | 2 +- .../cw-vesting/schema/cw-vesting.json | 2 +- .../cw721-roles/schema/cw721-roles.json | 2 +- .../dao-migrator/schema/dao-migrator.json | 2 +- .../dao-pre-propose-approval-single.json | 354 +++++++- .../src/tests.rs | 780 ++++++++++++++++- .../schema/dao-pre-propose-approver.json | 268 +++++- .../dao-pre-propose-approver/src/contract.rs | 22 +- .../dao-pre-propose-approver/src/tests.rs | 193 +++- .../schema/dao-pre-propose-multiple.json | 354 +++++++- .../dao-pre-propose-multiple/src/contract.rs | 17 +- .../dao-pre-propose-multiple/src/tests.rs | 828 +++++++++++++++++- .../schema/dao-pre-propose-single.json | 354 +++++++- .../dao-pre-propose-single/src/contract.rs | 17 +- .../dao-pre-propose-single/src/tests.rs | 825 ++++++++++++++++- .../schema/dao-proposal-condorcet.json | 2 +- .../schema/dao-proposal-multiple.json | 3 +- .../src/testing/instantiate.rs | 20 +- .../src/testing/tests.rs | 14 +- .../schema/dao-proposal-single.json | 3 +- .../src/testing/instantiate.rs | 15 +- .../dao-proposal-single/src/testing/tests.rs | 14 +- .../schema/cw20-stake-external-rewards.json | 2 +- .../schema/cw20-stake-reward-distributor.json | 2 +- .../staking/cw20-stake/schema/cw20-stake.json | 2 +- .../schema/dao-voting-cw20-staked.json | 2 +- .../dao-voting-cw4/schema/dao-voting-cw4.json | 2 +- .../schema/dao-voting-cw721-roles.json | 2 +- .../schema/dao-voting-cw721-staked.json | 2 +- .../schema/dao-voting-token-staked.json | 2 +- packages/dao-pre-propose-base/src/error.rs | 13 +- packages/dao-pre-propose-base/src/execute.rs | 288 +++++- packages/dao-pre-propose-base/src/msg.rs | 29 +- packages/dao-pre-propose-base/src/state.rs | 8 +- packages/dao-pre-propose-base/src/tests.rs | 4 +- packages/dao-voting/src/pre_propose.rs | 79 ++ 46 files changed, 4470 insertions(+), 323 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 486dc1046..6f6eeed06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -271,12 +271,12 @@ dependencies = [ "cw-admin-factory", "cw-utils 1.0.3", "cw20 1.1.2", - "cw20-stake 2.4.2", + "cw20-stake 2.5.0", "dao-dao-core", "dao-interface", "dao-pre-propose-single", "dao-proposal-single", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "dao-voting-cw20-staked", "env_logger", "serde", @@ -663,7 +663,7 @@ dependencies = [ [[package]] name = "cw-admin-factory" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -789,7 +789,7 @@ dependencies = [ [[package]] name = "cw-denom" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -801,18 +801,18 @@ dependencies = [ [[package]] name = "cw-fund-distributor" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", "cw-multi-test", - "cw-paginate-storage 2.4.2", + "cw-paginate-storage 2.5.0", "cw-storage-plus 1.2.0", "cw-utils 1.0.3", "cw2 1.1.2", "cw20 1.1.2", "cw20-base 1.1.2", - "cw20-stake 2.4.2", + "cw20-stake 2.5.0", "dao-dao-core", "dao-interface", "dao-voting-cw20-staked", @@ -821,7 +821,7 @@ dependencies = [ [[package]] name = "cw-hooks" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -888,7 +888,7 @@ dependencies = [ [[package]] name = "cw-paginate-storage" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-std", "cw-multi-test", @@ -898,7 +898,7 @@ dependencies = [ [[package]] name = "cw-payroll-factory" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -942,7 +942,7 @@ dependencies = [ [[package]] name = "cw-stake-tracker" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -995,7 +995,7 @@ dependencies = [ [[package]] name = "cw-token-swap" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1010,7 +1010,7 @@ dependencies = [ [[package]] name = "cw-tokenfactory-issuer" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1031,7 +1031,7 @@ dependencies = [ [[package]] name = "cw-tokenfactory-types" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1101,7 +1101,7 @@ dependencies = [ [[package]] name = "cw-vesting" -version = "2.4.2" +version = "2.5.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -1124,7 +1124,7 @@ dependencies = [ [[package]] name = "cw-wormhole" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1291,7 +1291,7 @@ dependencies = [ [[package]] name = "cw20-stake" -version = "2.4.2" +version = "2.5.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -1300,7 +1300,7 @@ dependencies = [ "cw-hooks", "cw-multi-test", "cw-ownable", - "cw-paginate-storage 2.4.2", + "cw-paginate-storage 2.5.0", "cw-storage-plus 1.2.0", "cw-utils 0.13.4", "cw-utils 1.0.3", @@ -1309,13 +1309,13 @@ dependencies = [ "cw20-base 1.1.2", "cw20-stake 0.2.6", "dao-hooks", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "thiserror", ] [[package]] name = "cw20-stake-external-rewards" -version = "2.4.2" +version = "2.5.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -1329,7 +1329,7 @@ dependencies = [ "cw20 0.13.4", "cw20 1.1.2", "cw20-base 1.1.2", - "cw20-stake 2.4.2", + "cw20-stake 2.5.0", "dao-hooks", "stake-cw20-external-rewards", "thiserror", @@ -1337,7 +1337,7 @@ dependencies = [ [[package]] name = "cw20-stake-reward-distributor" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1348,7 +1348,7 @@ dependencies = [ "cw2 1.1.2", "cw20 1.1.2", "cw20-base 1.1.2", - "cw20-stake 2.4.2", + "cw20-stake 2.5.0", "stake-cw20-reward-distributor", "thiserror", ] @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "cw721-controllers" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1552,7 +1552,7 @@ dependencies = [ [[package]] name = "cw721-roles" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1574,7 +1574,7 @@ dependencies = [ [[package]] name = "dao-cw721-extensions" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1584,13 +1584,13 @@ dependencies = [ [[package]] name = "dao-dao-core" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", "cw-core", "cw-multi-test", - "cw-paginate-storage 2.4.2", + "cw-paginate-storage 2.5.0", "cw-storage-plus 1.2.0", "cw-utils 1.0.3", "cw2 1.1.2", @@ -1607,13 +1607,13 @@ dependencies = [ [[package]] name = "dao-dao-macros" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", "cw-hooks", "dao-interface", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "proc-macro2", "quote", "syn 1.0.109", @@ -1621,19 +1621,19 @@ dependencies = [ [[package]] name = "dao-hooks" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", "cw-hooks", "cw4 1.1.2", "dao-pre-propose-base", - "dao-voting 2.4.2", + "dao-voting 2.5.0", ] [[package]] name = "dao-interface" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1646,7 +1646,7 @@ dependencies = [ [[package]] name = "dao-migrator" -version = "2.4.2" +version = "2.5.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -1663,7 +1663,7 @@ dependencies = [ "cw20 1.1.2", "cw20-base 1.1.2", "cw20-stake 0.2.6", - "cw20-stake 2.4.2", + "cw20-stake 2.5.0", "cw20-staked-balance-voting", "cw4 0.13.4", "cw4-voting", @@ -1672,7 +1672,7 @@ dependencies = [ "dao-proposal-single", "dao-testing", "dao-voting 0.1.0", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "dao-voting-cw20-staked", "dao-voting-cw4", "thiserror", @@ -1680,13 +1680,13 @@ dependencies = [ [[package]] name = "dao-pre-propose-approval-single" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", "cw-denom", "cw-multi-test", - "cw-paginate-storage 2.4.2", + "cw-paginate-storage 2.5.0", "cw-storage-plus 1.2.0", "cw-utils 1.0.3", "cw2 1.1.2", @@ -1699,7 +1699,7 @@ dependencies = [ "dao-pre-propose-base", "dao-proposal-single", "dao-testing", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "dao-voting-cw20-staked", "dao-voting-cw4", "thiserror", @@ -1707,7 +1707,7 @@ dependencies = [ [[package]] name = "dao-pre-propose-approver" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1726,14 +1726,14 @@ dependencies = [ "dao-pre-propose-base", "dao-proposal-single", "dao-testing", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "dao-voting-cw20-staked", "dao-voting-cw4", ] [[package]] name = "dao-pre-propose-base" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1744,14 +1744,14 @@ dependencies = [ "cw-utils 1.0.3", "cw2 1.1.2", "dao-interface", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "serde", "thiserror", ] [[package]] name = "dao-pre-propose-multiple" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1768,14 +1768,14 @@ dependencies = [ "dao-pre-propose-base", "dao-proposal-multiple", "dao-testing", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "dao-voting-cw20-staked", "dao-voting-cw4", ] [[package]] name = "dao-pre-propose-single" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1793,14 +1793,14 @@ dependencies = [ "dao-pre-propose-base", "dao-proposal-single", "dao-testing", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "dao-voting-cw20-staked", "dao-voting-cw4", ] [[package]] name = "dao-proposal-condorcet" -version = "2.4.2" +version = "2.5.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -1815,14 +1815,14 @@ dependencies = [ "dao-dao-macros", "dao-interface", "dao-testing", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "dao-voting-cw4", "thiserror", ] [[package]] name = "dao-proposal-hook-counter" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1837,14 +1837,14 @@ dependencies = [ "dao-hooks", "dao-interface", "dao-proposal-single", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "dao-voting-cw20-balance", "thiserror", ] [[package]] name = "dao-proposal-multiple" -version = "2.4.2" +version = "2.5.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -1857,7 +1857,7 @@ dependencies = [ "cw2 1.1.2", "cw20 1.1.2", "cw20-base 1.1.2", - "cw20-stake 2.4.2", + "cw20-stake 2.5.0", "cw4 1.1.2", "cw4-group 1.1.2", "cw721-base 0.18.0", @@ -1868,7 +1868,7 @@ dependencies = [ "dao-pre-propose-multiple", "dao-testing", "dao-voting 0.1.0", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "dao-voting-cw20-balance", "dao-voting-cw20-staked", "dao-voting-cw4", @@ -1880,7 +1880,7 @@ dependencies = [ [[package]] name = "dao-proposal-single" -version = "2.4.2" +version = "2.5.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -1896,7 +1896,7 @@ dependencies = [ "cw2 1.1.2", "cw20 1.1.2", "cw20-base 1.1.2", - "cw20-stake 2.4.2", + "cw20-stake 2.5.0", "cw4 1.1.2", "cw4-group 1.1.2", "cw721-base 0.18.0", @@ -1908,7 +1908,7 @@ dependencies = [ "dao-pre-propose-single", "dao-testing", "dao-voting 0.1.0", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "dao-voting-cw20-balance", "dao-voting-cw20-staked", "dao-voting-cw4", @@ -1919,7 +1919,7 @@ dependencies = [ [[package]] name = "dao-proposal-sudo" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1933,7 +1933,7 @@ dependencies = [ [[package]] name = "dao-rewards-distributor" -version = "2.4.2" +version = "2.5.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -1946,14 +1946,14 @@ dependencies = [ "cw2 1.1.2", "cw20 1.1.2", "cw20-base 1.1.2", - "cw20-stake 2.4.2", + "cw20-stake 2.5.0", "cw4 1.1.2", "cw4-group 1.1.2", "cw721-base 0.18.0", "dao-hooks", "dao-interface", "dao-testing", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "dao-voting-cw20-staked", "dao-voting-cw4", "dao-voting-cw721-staked", @@ -1963,7 +1963,7 @@ dependencies = [ [[package]] name = "dao-test-custom-factory" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1977,13 +1977,13 @@ dependencies = [ "cw721-base 0.18.0", "dao-dao-macros", "dao-interface", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "thiserror", ] [[package]] name = "dao-testing" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1997,7 +1997,7 @@ dependencies = [ "cw2 1.1.2", "cw20 1.1.2", "cw20-base 1.1.2", - "cw20-stake 2.4.2", + "cw20-stake 2.5.0", "cw4 1.1.2", "cw4-group 1.1.2", "cw721-base 0.18.0", @@ -2010,7 +2010,7 @@ dependencies = [ "dao-proposal-single", "dao-test-custom-factory", "dao-voting 0.1.0", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "dao-voting-cw20-balance", "dao-voting-cw20-staked", "dao-voting-cw4", @@ -2039,7 +2039,7 @@ dependencies = [ [[package]] name = "dao-voting" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -2054,7 +2054,7 @@ dependencies = [ [[package]] name = "dao-voting-cw20-balance" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -2071,7 +2071,7 @@ dependencies = [ [[package]] name = "dao-voting-cw20-staked" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -2081,16 +2081,16 @@ dependencies = [ "cw2 1.1.2", "cw20 1.1.2", "cw20-base 1.1.2", - "cw20-stake 2.4.2", + "cw20-stake 2.5.0", "dao-dao-macros", "dao-interface", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "thiserror", ] [[package]] name = "dao-voting-cw4" -version = "2.4.2" +version = "2.5.0" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -2107,7 +2107,7 @@ dependencies = [ [[package]] name = "dao-voting-cw721-roles" -version = "2.4.2" +version = "2.5.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -2131,7 +2131,7 @@ dependencies = [ [[package]] name = "dao-voting-cw721-staked" -version = "2.4.2" +version = "2.5.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -2152,7 +2152,7 @@ dependencies = [ "dao-proposal-single", "dao-test-custom-factory", "dao-testing", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "osmosis-std", "osmosis-test-tube", "serde", @@ -2161,7 +2161,7 @@ dependencies = [ [[package]] name = "dao-voting-token-staked" -version = "2.4.2" +version = "2.5.0" dependencies = [ "anyhow", "cosmwasm-schema", @@ -2181,7 +2181,7 @@ dependencies = [ "dao-proposal-single", "dao-test-custom-factory", "dao-testing", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "osmosis-std", "osmosis-test-tube", "serde", @@ -2869,7 +2869,7 @@ dependencies = [ "cw-vesting", "cw20 1.1.2", "cw20-base 1.1.2", - "cw20-stake 2.4.2", + "cw20-stake 2.5.0", "cw721 0.18.0", "cw721-base 0.18.0", "cw721-roles", @@ -2878,7 +2878,7 @@ dependencies = [ "dao-pre-propose-single", "dao-proposal-single", "dao-test-custom-factory", - "dao-voting 2.4.2", + "dao-voting 2.5.0", "dao-voting-cw20-staked", "dao-voting-cw721-staked", "env_logger", diff --git a/Cargo.toml b/Cargo.toml index 2f2d2ebe4..be1a87726 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ resolver = "2" edition = "2021" license = "BSD-3-Clause" repository = "https://github.com/DA0-DA0/dao-contracts" -version = "2.4.2" +version = "2.5.0" [profile.release] codegen-units = 1 @@ -81,45 +81,45 @@ wynd-utils = "0.4" # optional owner. cw-ownable = "0.5" -cw-admin-factory = { path = "./contracts/external/cw-admin-factory", version = "2.4.2" } -cw-denom = { path = "./packages/cw-denom", version = "2.4.2" } -cw-fund-distributor = { path = "./contracts/distribution/cw-fund-distributor", version = "2.4.2" } -cw-hooks = { path = "./packages/cw-hooks", version = "2.4.2" } -cw-paginate-storage = { path = "./packages/cw-paginate-storage", version = "2.4.2" } -cw-payroll-factory = { path = "./contracts/external/cw-payroll-factory", version = "2.4.2" } -cw-stake-tracker = { path = "./packages/cw-stake-tracker", version = "2.4.2" } -cw-tokenfactory-issuer = { path = "./contracts/external/cw-tokenfactory-issuer", version = "2.4.2", default-features = false } -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-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" } -dao-cw721-extensions = { path = "./packages/dao-cw721-extensions", version = "2.4.2" } -dao-dao-core = { path = "./contracts/dao-dao-core", version = "2.4.2" } -dao-dao-macros = { path = "./packages/dao-dao-macros", version = "2.4.2" } -dao-hooks = { path = "./packages/dao-hooks", version = "2.4.2" } -dao-interface = { path = "./packages/dao-interface", version = "2.4.2" } -dao-pre-propose-approval-single = { path = "./contracts/pre-propose/dao-pre-propose-approval-single", version = "2.4.2" } -dao-pre-propose-approver = { path = "./contracts/pre-propose/dao-pre-propose-approver", version = "2.4.2" } -dao-pre-propose-base = { path = "./packages/dao-pre-propose-base", version = "2.4.2" } -dao-pre-propose-multiple = { path = "./contracts/pre-propose/dao-pre-propose-multiple", version = "2.4.2" } -dao-pre-propose-single = { path = "./contracts/pre-propose/dao-pre-propose-single", version = "2.4.2" } -dao-proposal-condorcet = { path = "./contracts/proposal/dao-proposal-condorcet", version = "2.4.2" } -dao-proposal-hook-counter = { path = "./contracts/test/dao-proposal-hook-counter", version = "2.4.2" } -dao-proposal-multiple = { path = "./contracts/proposal/dao-proposal-multiple", version = "2.4.2" } -dao-proposal-single = { path = "./contracts/proposal/dao-proposal-single", version = "2.4.2" } -dao-proposal-sudo = { path = "./contracts/test/dao-proposal-sudo", version = "2.4.2" } -dao-rewards-distributor = { path = "./contracts/distribution/dao-rewards-distributor", version = "2.4.2" } -dao-test-custom-factory = { path = "./contracts/test/dao-test-custom-factory", version = "2.4.2" } -dao-testing = { path = "./packages/dao-testing", version = "2.4.2" } -dao-voting = { path = "./packages/dao-voting", version = "2.4.2" } -dao-voting-cw20-balance = { path = "./contracts/test/dao-voting-cw20-balance", version = "2.4.2" } -dao-voting-cw20-staked = { path = "./contracts/voting/dao-voting-cw20-staked", version = "2.4.2" } -dao-voting-cw4 = { path = "./contracts/voting/dao-voting-cw4", version = "2.4.2" } -dao-voting-cw721-roles = { path = "./contracts/voting/dao-voting-cw721-roles", version = "2.4.2" } -dao-voting-cw721-staked = { path = "./contracts/voting/dao-voting-cw721-staked", version = "2.4.2" } -dao-voting-token-staked = { path = "./contracts/voting/dao-voting-token-staked", version = "2.4.2" } +cw-admin-factory = { path = "./contracts/external/cw-admin-factory", version = "2.5.0" } +cw-denom = { path = "./packages/cw-denom", version = "2.5.0" } +cw-fund-distributor = { path = "./contracts/distribution/cw-fund-distributor", version = "2.5.0" } +cw-hooks = { path = "./packages/cw-hooks", version = "2.5.0" } +cw-paginate-storage = { path = "./packages/cw-paginate-storage", version = "2.5.0" } +cw-payroll-factory = { path = "./contracts/external/cw-payroll-factory", version = "2.5.0" } +cw-stake-tracker = { path = "./packages/cw-stake-tracker", version = "2.5.0" } +cw-tokenfactory-issuer = { path = "./contracts/external/cw-tokenfactory-issuer", version = "2.5.0", default-features = false } +cw-tokenfactory-types = { path = "./packages/cw-tokenfactory-types", version = "2.5.0", default-features = false } +cw-vesting = { path = "./contracts/external/cw-vesting", version = "2.5.0" } +cw-wormhole = { path = "./packages/cw-wormhole", version = "2.5.0" } +cw20-stake = { path = "./contracts/staking/cw20-stake", version = "2.5.0" } +cw721-controllers = { path = "./packages/cw721-controllers", version = "2.5.0" } +cw721-roles = { path = "./contracts/external/cw721-roles", version = "2.5.0" } +dao-cw721-extensions = { path = "./packages/dao-cw721-extensions", version = "2.5.0" } +dao-dao-core = { path = "./contracts/dao-dao-core", version = "2.5.0" } +dao-dao-macros = { path = "./packages/dao-dao-macros", version = "2.5.0" } +dao-hooks = { path = "./packages/dao-hooks", version = "2.5.0" } +dao-interface = { path = "./packages/dao-interface", version = "2.5.0" } +dao-pre-propose-approval-single = { path = "./contracts/pre-propose/dao-pre-propose-approval-single", version = "2.5.0" } +dao-pre-propose-approver = { path = "./contracts/pre-propose/dao-pre-propose-approver", version = "2.5.0" } +dao-pre-propose-base = { path = "./packages/dao-pre-propose-base", version = "2.5.0" } +dao-pre-propose-multiple = { path = "./contracts/pre-propose/dao-pre-propose-multiple", version = "2.5.0" } +dao-pre-propose-single = { path = "./contracts/pre-propose/dao-pre-propose-single", version = "2.5.0" } +dao-proposal-condorcet = { path = "./contracts/proposal/dao-proposal-condorcet", version = "2.5.0" } +dao-proposal-hook-counter = { path = "./contracts/test/dao-proposal-hook-counter", version = "2.5.0" } +dao-proposal-multiple = { path = "./contracts/proposal/dao-proposal-multiple", version = "2.5.0" } +dao-proposal-single = { path = "./contracts/proposal/dao-proposal-single", version = "2.5.0" } +dao-proposal-sudo = { path = "./contracts/test/dao-proposal-sudo", version = "2.5.0" } +dao-rewards-distributor = { path = "./contracts/distribution/dao-rewards-distributor", version = "2.5.0" } +dao-test-custom-factory = { path = "./contracts/test/dao-test-custom-factory", version = "2.5.0" } +dao-testing = { path = "./packages/dao-testing", version = "2.5.0" } +dao-voting = { path = "./packages/dao-voting", version = "2.5.0" } +dao-voting-cw20-balance = { path = "./contracts/test/dao-voting-cw20-balance", version = "2.5.0" } +dao-voting-cw20-staked = { path = "./contracts/voting/dao-voting-cw20-staked", version = "2.5.0" } +dao-voting-cw4 = { path = "./contracts/voting/dao-voting-cw4", version = "2.5.0" } +dao-voting-cw721-roles = { path = "./contracts/voting/dao-voting-cw721-roles", version = "2.5.0" } +dao-voting-cw721-staked = { path = "./contracts/voting/dao-voting-cw721-staked", version = "2.5.0" } +dao-voting-token-staked = { path = "./contracts/voting/dao-voting-token-staked", version = "2.5.0" } # v1 dependencies. used for state migrations. cw-core-v1 = { package = "cw-core", version = "0.1.0" } diff --git a/ci/bootstrap-env/src/main.rs b/ci/bootstrap-env/src/main.rs index 83d6b82c9..e32e4f558 100644 --- a/ci/bootstrap-env/src/main.rs +++ b/ci/bootstrap-env/src/main.rs @@ -4,6 +4,7 @@ use cosm_orc::{config::cfg::Config, orchestrator::cosm_orc::CosmOrc}; use cosmwasm_std::{to_json_binary, Decimal, Empty, Uint128}; use cw20::Cw20Coin; use dao_interface::state::{Admin, ModuleInstantiateInfo}; +use dao_voting::pre_propose::PreProposeSubmissionPolicy; use dao_voting::{ deposit::{DepositRefundPolicy, DepositToken, UncheckedDepositInfo, VotingModuleTokenType}, pre_propose::PreProposeInfo, @@ -99,7 +100,11 @@ fn main() -> Result<()> { amount: Uint128::new(1000000000), refund_policy: DepositRefundPolicy::OnlyPassed, }), - open_proposal_submission: false, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, extension: Empty::default(), }) .unwrap(), diff --git a/ci/integration-tests/src/helpers/helper.rs b/ci/integration-tests/src/helpers/helper.rs index e3bb76558..95b0e55bb 100644 --- a/ci/integration-tests/src/helpers/helper.rs +++ b/ci/integration-tests/src/helpers/helper.rs @@ -8,7 +8,7 @@ use dao_interface::query::DumpStateResponse; use dao_interface::state::{Admin, ModuleInstantiateInfo}; use dao_voting::{ deposit::{DepositRefundPolicy, DepositToken, UncheckedDepositInfo, VotingModuleTokenType}, - pre_propose::{PreProposeInfo, ProposalCreationPolicy}, + pre_propose::{PreProposeInfo, PreProposeSubmissionPolicy, ProposalCreationPolicy}, threshold::PercentageThreshold, threshold::Threshold, voting::Vote, @@ -84,7 +84,11 @@ pub fn create_dao( amount: DEPOSIT_AMOUNT, refund_policy: DepositRefundPolicy::OnlyPassed, }), - open_proposal_submission: false, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, extension: Empty::default(), }) .unwrap(), diff --git a/contracts/dao-dao-core/schema/dao-dao-core.json b/contracts/dao-dao-core/schema/dao-dao-core.json index 3ada83c21..b1cca4f3d 100644 --- a/contracts/dao-dao-core/schema/dao-dao-core.json +++ b/contracts/dao-dao-core/schema/dao-dao-core.json @@ -1,6 +1,6 @@ { "contract_name": "dao-dao-core", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/distribution/cw-fund-distributor/schema/cw-fund-distributor.json b/contracts/distribution/cw-fund-distributor/schema/cw-fund-distributor.json index 02b368e07..63fc4b821 100644 --- a/contracts/distribution/cw-fund-distributor/schema/cw-fund-distributor.json +++ b/contracts/distribution/cw-fund-distributor/schema/cw-fund-distributor.json @@ -1,6 +1,6 @@ { "contract_name": "cw-fund-distributor", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json index e7d00a62b..37f979a11 100644 --- a/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json +++ b/contracts/distribution/dao-rewards-distributor/schema/dao-rewards-distributor.json @@ -1,6 +1,6 @@ { "contract_name": "dao-rewards-distributor", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/external/cw-admin-factory/schema/cw-admin-factory.json b/contracts/external/cw-admin-factory/schema/cw-admin-factory.json index a85308e6e..efba62576 100644 --- a/contracts/external/cw-admin-factory/schema/cw-admin-factory.json +++ b/contracts/external/cw-admin-factory/schema/cw-admin-factory.json @@ -1,6 +1,6 @@ { "contract_name": "cw-admin-factory", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/external/cw-payroll-factory/schema/cw-payroll-factory.json b/contracts/external/cw-payroll-factory/schema/cw-payroll-factory.json index 964a41762..d9eb0eb32 100644 --- a/contracts/external/cw-payroll-factory/schema/cw-payroll-factory.json +++ b/contracts/external/cw-payroll-factory/schema/cw-payroll-factory.json @@ -1,6 +1,6 @@ { "contract_name": "cw-payroll-factory", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/external/cw-token-swap/schema/cw-token-swap.json b/contracts/external/cw-token-swap/schema/cw-token-swap.json index 4d5db2501..0875c942f 100644 --- a/contracts/external/cw-token-swap/schema/cw-token-swap.json +++ b/contracts/external/cw-token-swap/schema/cw-token-swap.json @@ -1,6 +1,6 @@ { "contract_name": "cw-token-swap", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/external/cw-tokenfactory-issuer/schema/cw-tokenfactory-issuer.json b/contracts/external/cw-tokenfactory-issuer/schema/cw-tokenfactory-issuer.json index 2ff6b757c..6b5e40a2a 100644 --- a/contracts/external/cw-tokenfactory-issuer/schema/cw-tokenfactory-issuer.json +++ b/contracts/external/cw-tokenfactory-issuer/schema/cw-tokenfactory-issuer.json @@ -1,6 +1,6 @@ { "contract_name": "cw-tokenfactory-issuer", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/external/cw-vesting/schema/cw-vesting.json b/contracts/external/cw-vesting/schema/cw-vesting.json index d10586ec4..d26c50748 100644 --- a/contracts/external/cw-vesting/schema/cw-vesting.json +++ b/contracts/external/cw-vesting/schema/cw-vesting.json @@ -1,6 +1,6 @@ { "contract_name": "cw-vesting", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/external/cw721-roles/schema/cw721-roles.json b/contracts/external/cw721-roles/schema/cw721-roles.json index 27d75ef6c..d9a254380 100644 --- a/contracts/external/cw721-roles/schema/cw721-roles.json +++ b/contracts/external/cw721-roles/schema/cw721-roles.json @@ -1,6 +1,6 @@ { "contract_name": "cw721-roles", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/external/dao-migrator/schema/dao-migrator.json b/contracts/external/dao-migrator/schema/dao-migrator.json index 828c241ef..62f9371fc 100644 --- a/contracts/external/dao-migrator/schema/dao-migrator.json +++ b/contracts/external/dao-migrator/schema/dao-migrator.json @@ -1,6 +1,6 @@ { "contract_name": "dao-migrator", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/schema/dao-pre-propose-approval-single.json b/contracts/pre-propose/dao-pre-propose-approval-single/schema/dao-pre-propose-approval-single.json index d253aeb3a..c46026da1 100644 --- a/contracts/pre-propose/dao-pre-propose-approval-single/schema/dao-pre-propose-approval-single.json +++ b/contracts/pre-propose/dao-pre-propose-approval-single/schema/dao-pre-propose-approval-single.json @@ -1,6 +1,6 @@ { "contract_name": "dao-pre-propose-approval-single", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", @@ -8,7 +8,7 @@ "type": "object", "required": [ "extension", - "open_proposal_submission" + "submission_policy" ], "properties": { "deposit_info": { @@ -30,9 +30,13 @@ } ] }, - "open_proposal_submission": { - "description": "If false, only members (addresses with voting power) may create proposals in the DAO. Otherwise, any address may create a proposal so long as they pay the deposit.", - "type": "boolean" + "submission_policy": { + "description": "The policy dictating who is allowed to submit proposals.", + "allOf": [ + { + "$ref": "#/definitions/PreProposeSubmissionPolicy" + } + ] } }, "additionalProperties": false, @@ -123,6 +127,80 @@ }, "additionalProperties": false }, + "PreProposeSubmissionPolicy": { + "description": "The policy configured in a pre-propose module that determines who can submit proposals. This is the preferred way to restrict proposal creation (as opposed to the ProposalCreationPolicy above) since pre-propose modules support other features, such as proposal deposits.", + "oneOf": [ + { + "description": "Anyone may create proposals, except for those in the denylist.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "properties": { + "denylist": { + "description": "Addresses that may not create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Specific people may create proposals.", + "type": "object", + "required": [ + "specific" + ], + "properties": { + "specific": { + "type": "object", + "required": [ + "dao_members" + ], + "properties": { + "allowlist": { + "description": "Addresses that may create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "dao_members": { + "description": "Whether or not DAO members may create proposals.", + "type": "boolean" + }, + "denylist": { + "description": "Addresses that may not create proposals, overriding other settings.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "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" @@ -238,11 +316,9 @@ "properties": { "update_config": { "type": "object", - "required": [ - "open_proposal_submission" - ], "properties": { "deposit_info": { + "description": "If None, will remove the deposit. Backwards compatible.", "anyOf": [ { "$ref": "#/definitions/UncheckedDepositInfo" @@ -252,8 +328,79 @@ } ] }, - "open_proposal_submission": { - "type": "boolean" + "submission_policy": { + "description": "If None, will leave the submission policy in the config as-is.", + "anyOf": [ + { + "$ref": "#/definitions/PreProposeSubmissionPolicy" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Perform more granular submission policy updates to allow for atomic operations that don't override others.", + "type": "object", + "required": [ + "update_submission_policy" + ], + "properties": { + "update_submission_policy": { + "type": "object", + "properties": { + "allowlist_add": { + "description": "If using specific policy, optionally add to the allowlist.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "allowlist_remove": { + "description": "If using specific policy, optionally remove from the allowlist.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "denylist_add": { + "description": "Optionally add to the denylist. Works for any submission policy.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "denylist_remove": { + "description": "Optionally remove from denylist. Works for any submission policy.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "set_dao_members": { + "description": "If using specific policy, optionally update the `dao_members` flag.", + "type": [ + "boolean", + "null" + ] } }, "additionalProperties": false @@ -1011,6 +1158,80 @@ } } }, + "PreProposeSubmissionPolicy": { + "description": "The policy configured in a pre-propose module that determines who can submit proposals. This is the preferred way to restrict proposal creation (as opposed to the ProposalCreationPolicy above) since pre-propose modules support other features, such as proposal deposits.", + "oneOf": [ + { + "description": "Anyone may create proposals, except for those in the denylist.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "properties": { + "denylist": { + "description": "Addresses that may not create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Specific people may create proposals.", + "type": "object", + "required": [ + "specific" + ], + "properties": { + "specific": { + "type": "object", + "required": [ + "dao_members" + ], + "properties": { + "allowlist": { + "description": "Addresses that may create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "dao_members": { + "description": "Whether or not DAO members may create proposals.", + "type": "boolean" + }, + "denylist": { + "description": "Addresses that may not create proposals, overriding other settings.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, "ProposeMessage": { "oneOf": [ { @@ -1611,6 +1832,28 @@ }, "additionalProperties": false }, + { + "description": "Returns whether or not the address can submit proposals.", + "type": "object", + "required": [ + "can_propose" + ], + "properties": { + "can_propose": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, { "description": "Returns list of proposal submitted hooks.", "type": "object", @@ -1918,12 +2161,17 @@ "migrate": null, "sudo": null, "responses": { + "can_propose": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Boolean", + "type": "boolean" + }, "config": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Config", "type": "object", "required": [ - "open_proposal_submission" + "submission_policy" ], "properties": { "deposit_info": { @@ -1937,9 +2185,13 @@ } ] }, - "open_proposal_submission": { - "description": "If false, only members (addresses with voting power) may create proposals in the DAO. Otherwise, any address may create a proposal so long as they pay the deposit.", - "type": "boolean" + "submission_policy": { + "description": "The policy dictating who is allowed to submit proposals.", + "allOf": [ + { + "$ref": "#/definitions/PreProposeSubmissionPolicy" + } + ] } }, "additionalProperties": false, @@ -2040,6 +2292,80 @@ } ] }, + "PreProposeSubmissionPolicy": { + "description": "The policy configured in a pre-propose module that determines who can submit proposals. This is the preferred way to restrict proposal creation (as opposed to the ProposalCreationPolicy above) since pre-propose modules support other features, such as proposal deposits.", + "oneOf": [ + { + "description": "Anyone may create proposals, except for those in the denylist.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "properties": { + "denylist": { + "description": "Addresses that may not create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Specific people may create proposals.", + "type": "object", + "required": [ + "specific" + ], + "properties": { + "specific": { + "type": "object", + "required": [ + "dao_members" + ], + "properties": { + "allowlist": { + "description": "Addresses that may create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "dao_members": { + "description": "Whether or not DAO members may create proposals.", + "type": "boolean" + }, + "denylist": { + "description": "Addresses that may not create proposals, overriding other settings.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "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" diff --git a/contracts/pre-propose/dao-pre-propose-approval-single/src/tests.rs b/contracts/pre-propose/dao-pre-propose-approval-single/src/tests.rs index 99b8c0369..0705780fc 100644 --- a/contracts/pre-propose/dao-pre-propose-approval-single/src/tests.rs +++ b/contracts/pre-propose/dao-pre-propose-approval-single/src/tests.rs @@ -9,6 +9,7 @@ use dao_interface::state::{Admin, ModuleInstantiateInfo}; use dao_pre_propose_base::{error::PreProposeError, msg::DepositInfoResponse, state::Config}; use dao_proposal_single::query::ProposalResponse; use dao_testing::helpers::instantiate_with_cw4_groups_governance; +use dao_voting::pre_propose::{PreProposeSubmissionPolicy, PreProposeSubmissionPolicyError}; use dao_voting::{ deposit::{CheckedDepositInfo, DepositRefundPolicy, DepositToken, UncheckedDepositInfo}, pre_propose::{PreProposeInfo, ProposalCreationPolicy}, @@ -52,6 +53,16 @@ fn get_default_proposal_module_instantiate( ) -> dao_proposal_single::msg::InstantiateMsg { let pre_propose_id = app.store_code(cw_pre_propose_base_proposal_single()); + let submission_policy = if open_proposal_submission { + PreProposeSubmissionPolicy::Anyone { denylist: None } + } else { + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + } + }; + dao_proposal_single::msg::InstantiateMsg { threshold: Threshold::AbsolutePercentage { percentage: PercentageThreshold::Majority {}, @@ -65,7 +76,7 @@ fn get_default_proposal_module_instantiate( code_id: pre_propose_id, msg: to_json_binary(&InstantiateMsg { deposit_info, - open_proposal_submission, + submission_policy, extension: InstantiateExt { approver: "approver".to_string(), }, @@ -298,19 +309,30 @@ fn get_deposit_info(app: &App, module: Addr, id: u64) -> DepositInfoResponse { .unwrap() } +fn query_can_propose(app: &App, module: Addr, address: impl Into) -> bool { + app.wrap() + .query_wasm_smart( + module, + &QueryMsg::CanPropose { + address: address.into(), + }, + ) + .unwrap() +} + fn update_config( app: &mut App, module: Addr, sender: &str, deposit_info: Option, - open_proposal_submission: bool, + submission_policy: PreProposeSubmissionPolicy, ) -> Config { app.execute_contract( Addr::unchecked(sender), module.clone(), &ExecuteMsg::UpdateConfig { deposit_info, - open_proposal_submission, + submission_policy: Some(submission_policy), }, &[], ) @@ -324,14 +346,14 @@ fn update_config_should_fail( module: Addr, sender: &str, deposit_info: Option, - open_proposal_submission: bool, + submission_policy: PreProposeSubmissionPolicy, ) -> PreProposeError { app.execute_contract( Addr::unchecked(sender), module, &ExecuteMsg::UpdateConfig { deposit_info, - open_proposal_submission, + submission_policy: Some(submission_policy), }, &[], ) @@ -1165,7 +1187,10 @@ fn test_permissions() { .unwrap_err() .downcast() .unwrap(); - assert_eq!(err, PreProposeError::NotMember {}); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); } #[test] @@ -1314,7 +1339,10 @@ fn test_no_deposit_required_members_submission() { .unwrap_err() .downcast() .unwrap(); - assert_eq!(err, PreProposeError::NotMember {}); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); let pre_propose_id = make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); @@ -1325,6 +1353,212 @@ fn test_no_deposit_required_members_submission() { assert_eq!(Status::Passed, new_status) } +#[test] +fn test_anyone_denylist() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr, + pre_propose, + .. + } = setup_default_test(&mut app, None, false); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Anyone { denylist: None }, + ); + + let rando = "rando"; + + // Proposal succeeds when anyone can propose. + assert!(query_can_propose(&app, pre_propose.clone(), rando)); + make_pre_proposal(&mut app, pre_propose.clone(), rando, &[]); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Anyone { + denylist: Some(vec![rando.to_string()]), + }, + ); + + // Proposing fails if on denylist. + assert!(!query_can_propose(&app, pre_propose.clone(), rando)); + let err: PreProposeError = app + .execute_contract( + Addr::unchecked(rando), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "I would like to join the DAO".to_string(), + description: "though, I am currently not a member.".to_string(), + msgs: vec![], + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + // Proposing succeeds if not on denylist. + assert!(query_can_propose(&app, pre_propose.clone(), "ekez")); + make_pre_proposal(&mut app, pre_propose, "ekez", &[]); +} + +#[test] +fn test_specific_allowlist_denylist() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr, + pre_propose, + .. + } = setup_default_test(&mut app, None, false); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, + ); + + // Proposal succeeds for member. + assert!(query_can_propose(&app, pre_propose.clone(), "ekez")); + make_pre_proposal(&mut app, pre_propose.clone(), "ekez", &[]); + + let rando = "rando"; + + // Proposing fails for non-member. + assert!(!query_can_propose(&app, pre_propose.clone(), rando)); + let err: PreProposeError = app + .execute_contract( + Addr::unchecked(rando), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "I would like to join the DAO".to_string(), + description: "though, I am currently not a member.".to_string(), + msgs: vec![], + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: Some(vec![rando.to_string()]), + denylist: None, + }, + ); + + // Proposal succeeds if on allowlist. + assert!(query_can_propose(&app, pre_propose.clone(), rando)); + make_pre_proposal(&mut app, pre_propose.clone(), rando, &[]); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: Some(vec![rando.to_string()]), + denylist: Some(vec!["ekez".to_string()]), + }, + ); + + // Proposing fails if on denylist. + assert!(!query_can_propose(&app, pre_propose.clone(), "ekez")); + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("ekez"), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "Let me propose!".to_string(), + description: "I am a member!!!".to_string(), + msgs: vec![], + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: Some(vec![rando.to_string()]), + denylist: None, + }, + ); + + // Proposing fails if members not allowed. + assert!(!query_can_propose(&app, pre_propose.clone(), "ekez")); + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("ekez"), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "Let me propose!".to_string(), + description: "I am a member!!!".to_string(), + msgs: vec![], + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + // Proposal succeeds if on allowlist. + assert!(query_can_propose(&app, pre_propose.clone(), rando)); + make_pre_proposal(&mut app, pre_propose.clone(), rando, &[]); +} + #[test] #[should_panic(expected = "invalid zero deposit. set the deposit to `None` to have no deposit")] fn test_instantiate_with_zero_native_deposit() { @@ -1354,7 +1588,11 @@ fn test_instantiate_with_zero_native_deposit() { amount: Uint128::zero(), refund_policy: DepositRefundPolicy::OnlyPassed, }), - open_proposal_submission: false, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, extension: InstantiateExt { approver: "approver".to_string(), }, @@ -1419,7 +1657,11 @@ fn test_instantiate_with_zero_cw20_deposit() { amount: Uint128::zero(), refund_policy: DepositRefundPolicy::OnlyPassed, }), - open_proposal_submission: false, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, extension: InstantiateExt { approver: "approver".to_string(), }, @@ -1467,7 +1709,11 @@ fn test_update_config() { config, Config { deposit_info: None, - open_proposal_submission: false + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None + } } ); @@ -1487,7 +1733,7 @@ fn test_update_config() { amount: Uint128::new(10), refund_policy: DepositRefundPolicy::Never, }), - true, + PreProposeSubmissionPolicy::Anyone { denylist: None }, ); let config = get_config(&app, pre_propose.clone()); @@ -1499,7 +1745,7 @@ fn test_update_config() { amount: Uint128::new(10), refund_policy: DepositRefundPolicy::Never }), - open_proposal_submission: true, + submission_policy: PreProposeSubmissionPolicy::Anyone { denylist: None }, } ); @@ -1549,9 +1795,503 @@ fn test_update_config() { assert_eq!(balance, Uint128::new(0)); // Only the core module can update the config. - let err = - update_config_should_fail(&mut app, pre_propose, proposal_single.as_str(), None, true); + let err = update_config_should_fail( + &mut app, + pre_propose.clone(), + proposal_single.as_str(), + None, + PreProposeSubmissionPolicy::Anyone { denylist: None }, + ); assert_eq!(err, PreProposeError::NotDao {}); + + // Errors when no one is authorized to create proposals. + let err = update_config_should_fail( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: None, + denylist: None, + }, + ); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::NoOneAllowed {}) + ); + + // Errors when allowlist and denylist overlap. + let err = update_config_should_fail( + &mut app, + pre_propose, + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: Some(vec!["ekez".to_string()]), + denylist: Some(vec!["ekez".to_string()]), + }, + ); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::DenylistAllowlistOverlap {} + ) + ); +} + +#[test] +fn test_update_submission_policy() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr, + pre_propose, + .. + } = setup_default_test(&mut app, None, true); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { denylist: None }, + } + ); + + // Only the core module can update the submission policy. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("ekez"), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotDao {}); + + // Append to denylist, with auto de-dupe. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string(), "ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { + denylist: Some(vec!["ekez".to_string()]), + }, + } + ); + + // Add and remove to/from denylist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["someone".to_string(), "else".to_string()]), + denylist_remove: Some(vec!["ekez".to_string()]), + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { + denylist: Some(vec!["someone".to_string(), "else".to_string()]), + }, + } + ); + + // Remove from denylist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: Some(vec!["someone".to_string(), "else".to_string()]), + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { denylist: None }, + } + ); + + // Error if try to change Specific fields when set to Anyone. + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: Some(true), + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::AnyoneInvalidUpdateFields {} + ) + ); + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: Some(vec!["ekez".to_string()]), + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::AnyoneInvalidUpdateFields {} + ) + ); + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: Some(vec!["ekez".to_string()]), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::AnyoneInvalidUpdateFields {} + ) + ); + + // Change to Specific policy. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateConfig { + deposit_info: None, + submission_policy: Some(PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }), + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, + } + ); + + // Append to denylist, with auto de-dupe. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string(), "ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: Some(vec!["ekez".to_string()]), + }, + } + ); + + // Add and remove to/from denylist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["someone".to_string(), "else".to_string()]), + denylist_remove: Some(vec!["ekez".to_string()]), + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: Some(vec!["someone".to_string(), "else".to_string()]), + }, + } + ); + + // Remove from denylist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: Some(vec!["someone".to_string(), "else".to_string()]), + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None + }, + } + ); + + // Append to allowlist, with auto de-dupe. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: Some(vec!["ekez".to_string(), "ekez".to_string()]), + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: Some(vec!["ekez".to_string()]), + denylist: None, + }, + } + ); + + // Add and remove to/from allowlist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: Some(vec!["someone".to_string(), "else".to_string()]), + allowlist_remove: Some(vec!["ekez".to_string()]), + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: Some(vec!["someone".to_string(), "else".to_string()]), + denylist: None, + }, + } + ); + + // Remove from allowlist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: Some(vec!["someone".to_string(), "else".to_string()]), + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None + }, + } + ); + + // Setting dao_members to false fails if allowlist is empty. + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: Some(false), + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::NoOneAllowed {}) + ); + + // Set dao_members to false and add allowlist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: Some(false), + allowlist_add: Some(vec!["ekez".to_string()]), + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: Some(vec!["ekez".to_string()]), + denylist: None + }, + } + ); + + // Errors when allowlist and denylist overlap. + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::DenylistAllowlistOverlap {} + ) + ); } #[test] @@ -1595,7 +2335,11 @@ fn test_withdraw() { amount: Uint128::new(10), refund_policy: DepositRefundPolicy::Always, }), - false, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, ); // Withdraw with no specified denom - should fall back to the one @@ -1642,7 +2386,11 @@ fn test_withdraw() { amount: Uint128::new(10), refund_policy: DepositRefundPolicy::Always, }), - false, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, ); increase_allowance( diff --git a/contracts/pre-propose/dao-pre-propose-approver/schema/dao-pre-propose-approver.json b/contracts/pre-propose/dao-pre-propose-approver/schema/dao-pre-propose-approver.json index ef387da81..769cd499d 100644 --- a/contracts/pre-propose/dao-pre-propose-approver/schema/dao-pre-propose-approver.json +++ b/contracts/pre-propose/dao-pre-propose-approver/schema/dao-pre-propose-approver.json @@ -1,6 +1,6 @@ { "contract_name": "dao-pre-propose-approver", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", @@ -51,11 +51,9 @@ "properties": { "update_config": { "type": "object", - "required": [ - "open_proposal_submission" - ], "properties": { "deposit_info": { + "description": "If None, will remove the deposit. Backwards compatible.", "anyOf": [ { "$ref": "#/definitions/UncheckedDepositInfo" @@ -65,8 +63,79 @@ } ] }, - "open_proposal_submission": { - "type": "boolean" + "submission_policy": { + "description": "If None, will leave the submission policy in the config as-is.", + "anyOf": [ + { + "$ref": "#/definitions/PreProposeSubmissionPolicy" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Perform more granular submission policy updates to allow for atomic operations that don't override others.", + "type": "object", + "required": [ + "update_submission_policy" + ], + "properties": { + "update_submission_policy": { + "type": "object", + "properties": { + "allowlist_add": { + "description": "If using specific policy, optionally add to the allowlist.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "allowlist_remove": { + "description": "If using specific policy, optionally remove from the allowlist.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "denylist_add": { + "description": "Optionally add to the denylist. Works for any submission policy.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "denylist_remove": { + "description": "Optionally remove from denylist. Works for any submission policy.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "set_dao_members": { + "description": "If using specific policy, optionally update the `dao_members` flag.", + "type": [ + "boolean", + "null" + ] } }, "additionalProperties": false @@ -370,6 +439,80 @@ } ] }, + "PreProposeSubmissionPolicy": { + "description": "The policy configured in a pre-propose module that determines who can submit proposals. This is the preferred way to restrict proposal creation (as opposed to the ProposalCreationPolicy above) since pre-propose modules support other features, such as proposal deposits.", + "oneOf": [ + { + "description": "Anyone may create proposals, except for those in the denylist.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "properties": { + "denylist": { + "description": "Addresses that may not create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Specific people may create proposals.", + "type": "object", + "required": [ + "specific" + ], + "properties": { + "specific": { + "type": "object", + "required": [ + "dao_members" + ], + "properties": { + "allowlist": { + "description": "Addresses that may create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "dao_members": { + "description": "Whether or not DAO members may create proposals.", + "type": "boolean" + }, + "denylist": { + "description": "Addresses that may not create proposals, overriding other settings.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, "Status": { "oneOf": [ { @@ -607,6 +750,28 @@ }, "additionalProperties": false }, + { + "description": "Returns whether or not the address can submit proposals.", + "type": "object", + "required": [ + "can_propose" + ], + "properties": { + "can_propose": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, { "description": "Returns list of proposal submitted hooks.", "type": "object", @@ -713,12 +878,17 @@ "migrate": null, "sudo": null, "responses": { + "can_propose": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Boolean", + "type": "boolean" + }, "config": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Config", "type": "object", "required": [ - "open_proposal_submission" + "submission_policy" ], "properties": { "deposit_info": { @@ -732,9 +902,13 @@ } ] }, - "open_proposal_submission": { - "description": "If false, only members (addresses with voting power) may create proposals in the DAO. Otherwise, any address may create a proposal so long as they pay the deposit.", - "type": "boolean" + "submission_policy": { + "description": "The policy dictating who is allowed to submit proposals.", + "allOf": [ + { + "$ref": "#/definitions/PreProposeSubmissionPolicy" + } + ] } }, "additionalProperties": false, @@ -835,6 +1009,80 @@ } ] }, + "PreProposeSubmissionPolicy": { + "description": "The policy configured in a pre-propose module that determines who can submit proposals. This is the preferred way to restrict proposal creation (as opposed to the ProposalCreationPolicy above) since pre-propose modules support other features, such as proposal deposits.", + "oneOf": [ + { + "description": "Anyone may create proposals, except for those in the denylist.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "properties": { + "denylist": { + "description": "Addresses that may not create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Specific people may create proposals.", + "type": "object", + "required": [ + "specific" + ], + "properties": { + "specific": { + "type": "object", + "required": [ + "dao_members" + ], + "properties": { + "allowlist": { + "description": "Addresses that may create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "dao_members": { + "description": "Whether or not DAO members may create proposals.", + "type": "boolean" + }, + "denylist": { + "description": "Addresses that may not create proposals, overriding other settings.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "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" diff --git a/contracts/pre-propose/dao-pre-propose-approver/src/contract.rs b/contracts/pre-propose/dao-pre-propose-approver/src/contract.rs index 119928320..9dcb23c93 100644 --- a/contracts/pre-propose/dao-pre-propose-approver/src/contract.rs +++ b/contracts/pre-propose/dao-pre-propose-approver/src/contract.rs @@ -11,6 +11,7 @@ use dao_pre_propose_approval_single::msg::{ ApproverProposeMessage, ExecuteExt as ApprovalExt, ExecuteMsg as PreProposeApprovalExecuteMsg, }; use dao_pre_propose_base::{error::PreProposeError, state::PreProposeContract}; +use dao_voting::pre_propose::PreProposeSubmissionPolicy; use dao_voting::status::Status; use crate::msg::{ @@ -33,11 +34,16 @@ pub fn instantiate( info: MessageInfo, msg: InstantiateMsg, ) -> Result { - // This contract does not handle deposits or have open submissions - // Here we hardcode the pre-propose-base instantiate message + // This contract does not handle deposits or allow submission permissions + // since only the approval-single contract can create proposals. Just + // hardcode the pre-propose-base instantiate message. let base_instantiate_msg = BaseInstantiateMsg { deposit_info: None, - open_proposal_submission: false, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, extension: Empty {}, }; // Default pre-propose-base instantiation @@ -92,6 +98,9 @@ pub fn execute( ExecuteMsg::Extension { msg } => match msg { ExecuteExt::ResetApprover {} => execute_reset_approver(deps, env, info), }, + // Override config updates since they don't apply. + ExecuteMsg::UpdateConfig { .. } => Err(PreProposeError::Unsupported {}), + ExecuteMsg::UpdateSubmissionPolicy { .. } => Err(PreProposeError::Unsupported {}), _ => PrePropose::default().execute(deps, env, info, msg), } } @@ -107,7 +116,7 @@ pub fn execute_propose( return Err(PreProposeError::Unauthorized {}); } - // Get pre_prospose_id, transform proposal for the approver + // Get pre_propose_id, transform proposal for the approver // Here we make sure that there are no messages that can be executed let (pre_propose_id, sanitized_msg) = match msg { ApproverProposeMessage::Propose { @@ -228,6 +237,11 @@ pub fn execute_reset_approver( #[cfg_attr(not(feature = "library"), entry_point)] pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { match msg { + QueryMsg::CanPropose { address } => { + let approval_contract = PRE_PROPOSE_APPROVAL_CONTRACT.load(deps.storage)?; + let can_propose = address == approval_contract; + to_json_binary(&can_propose) + } QueryMsg::QueryExtension { msg } => match msg { QueryExt::PreProposeApprovalContract {} => { to_json_binary(&PRE_PROPOSE_APPROVAL_CONTRACT.load(deps.storage)?) diff --git a/contracts/pre-propose/dao-pre-propose-approver/src/tests.rs b/contracts/pre-propose/dao-pre-propose-approver/src/tests.rs index fbef73e87..9cae15ff8 100644 --- a/contracts/pre-propose/dao-pre-propose-approver/src/tests.rs +++ b/contracts/pre-propose/dao-pre-propose-approver/src/tests.rs @@ -3,6 +3,7 @@ use cw2::ContractVersion; use cw20::Cw20Coin; use cw_denom::UncheckedDenom; use cw_multi_test::{App, BankSudo, Contract, ContractWrapper, Executor}; +use dao_voting::pre_propose::{PreProposeSubmissionPolicy, PreProposeSubmissionPolicyError}; use dps::query::{ProposalListResponse, ProposalResponse}; use dao_interface::state::ProposalModule; @@ -79,6 +80,16 @@ fn get_proposal_module_approval_single_instantiate( ) -> dps::msg::InstantiateMsg { let pre_propose_id = app.store_code(cw_pre_propose_base_proposal_single()); + let submission_policy = if open_proposal_submission { + PreProposeSubmissionPolicy::Anyone { denylist: None } + } else { + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + } + }; + dps::msg::InstantiateMsg { threshold: Threshold::AbsolutePercentage { percentage: PercentageThreshold::Majority {}, @@ -92,7 +103,7 @@ fn get_proposal_module_approval_single_instantiate( code_id: pre_propose_id, msg: to_json_binary(&InstantiateMsg { deposit_info, - open_proposal_submission, + submission_policy, extension: InstantiateExt { approver: APPROVER.to_string(), }, @@ -446,19 +457,30 @@ fn get_latest_proposal_id(app: &App, module: Addr) -> u64 { props.proposals[props.proposals.len() - 1].id } +fn query_can_propose(app: &App, module: Addr, address: impl Into) -> bool { + app.wrap() + .query_wasm_smart( + module, + &QueryMsg::CanPropose { + address: address.into(), + }, + ) + .unwrap() +} + fn update_config( app: &mut App, module: Addr, sender: &str, deposit_info: Option, - open_proposal_submission: bool, + submission_policy: PreProposeSubmissionPolicy, ) -> Config { app.execute_contract( Addr::unchecked(sender), module.clone(), &ExecuteMsg::UpdateConfig { deposit_info, - open_proposal_submission, + submission_policy: Some(submission_policy), }, &[], ) @@ -472,14 +494,14 @@ fn update_config_should_fail( module: Addr, sender: &str, deposit_info: Option, - open_proposal_submission: bool, + submission_policy: PreProposeSubmissionPolicy, ) -> PreProposeError { app.execute_contract( Addr::unchecked(sender), module, &ExecuteMsg::UpdateConfig { deposit_info, - open_proposal_submission, + submission_policy: Some(submission_policy), }, &[], ) @@ -1160,7 +1182,10 @@ fn test_permissions() { .unwrap_err() .downcast() .unwrap(); - assert_eq!(err, PreProposeError::NotMember {}); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); } #[test] @@ -1316,7 +1341,11 @@ fn test_update_config() { config, Config { deposit_info: None, - open_proposal_submission: false + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None + } } ); @@ -1343,7 +1372,7 @@ fn test_update_config() { amount: Uint128::new(10), refund_policy: DepositRefundPolicy::Never, }), - true, + PreProposeSubmissionPolicy::Anyone { denylist: None }, ); let config = get_config(&app, pre_propose.clone()); @@ -1355,7 +1384,7 @@ fn test_update_config() { amount: Uint128::new(10), refund_policy: DepositRefundPolicy::Never }), - open_proposal_submission: true, + submission_policy: PreProposeSubmissionPolicy::Anyone { denylist: None }, } ); @@ -1407,9 +1436,139 @@ fn test_update_config() { assert_eq!(balance, Uint128::new(0)); // Only the core module can update the config. - let err = - update_config_should_fail(&mut app, pre_propose, proposal_single.as_str(), None, true); + let err = update_config_should_fail( + &mut app, + pre_propose.clone(), + proposal_single.as_str(), + None, + PreProposeSubmissionPolicy::Anyone { denylist: None }, + ); assert_eq!(err, PreProposeError::NotDao {}); + + // Errors when no one is authorized to create proposals. + let err = update_config_should_fail( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: None, + denylist: None, + }, + ); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::NoOneAllowed {}) + ); + + // Errors when allowlist and denylist overlap. + let err = update_config_should_fail( + &mut app, + pre_propose, + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: Some(vec!["ekez".to_string()]), + denylist: Some(vec!["ekez".to_string()]), + }, + ); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::DenylistAllowlistOverlap {} + ) + ); +} + +#[test] +fn test_approver_unsupported_update_config() { + let mut app = App::default(); + + // Need to instantiate this so contract addresses match with cw20 test cases + let _ = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr, + pre_propose_approver, + .. + } = setup_default_test(&mut app, None, true); + + // Should fail because config is not supported for the approver pre-propose + // contract. + let err = update_config_should_fail( + &mut app, + pre_propose_approver, + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: Some(vec!["ekez".to_string()]), + denylist: None, + }, + ); + assert_eq!(err, PreProposeError::Unsupported {}); +} + +#[test] +fn test_approver_unsupported_update_submission_policy() { + let mut app = App::default(); + + // Need to instantiate this so contract addresses match with cw20 test cases + let _ = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + core_addr, + pre_propose_approver, + .. + } = setup_default_test(&mut app, None, true); + + // Should fail because submission policy is not supported for the approver + // pre-propose contract. + let err: PreProposeError = app + .execute_contract( + core_addr, + pre_propose_approver, + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::Unsupported {}); +} + +#[test] +fn test_approver_can_propose() { + let mut app = App::default(); + + // Need to instantiate this so contract addresses match with cw20 test cases + let _ = instantiate_cw20_base_default(&mut app); + + let DefaultTestSetup { + pre_propose, + pre_propose_approver, + .. + } = setup_default_test(&mut app, None, true); + + // Only the pre-propose-approval-single contract can propose. + assert!(query_can_propose( + &app, + pre_propose_approver.clone(), + pre_propose + )); + assert!(!query_can_propose( + &app, + pre_propose_approver, + "someone_else" + )); } #[test] @@ -1459,7 +1618,11 @@ fn test_withdraw() { amount: Uint128::new(10), refund_policy: DepositRefundPolicy::Always, }), - false, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, ); // Withdraw with no specified denom - should fall back to the one @@ -1508,7 +1671,11 @@ fn test_withdraw() { amount: Uint128::new(10), refund_policy: DepositRefundPolicy::Always, }), - false, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, ); increase_allowance( diff --git a/contracts/pre-propose/dao-pre-propose-multiple/schema/dao-pre-propose-multiple.json b/contracts/pre-propose/dao-pre-propose-multiple/schema/dao-pre-propose-multiple.json index 577862b00..f55f6359e 100644 --- a/contracts/pre-propose/dao-pre-propose-multiple/schema/dao-pre-propose-multiple.json +++ b/contracts/pre-propose/dao-pre-propose-multiple/schema/dao-pre-propose-multiple.json @@ -1,6 +1,6 @@ { "contract_name": "dao-pre-propose-multiple", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", @@ -8,7 +8,7 @@ "type": "object", "required": [ "extension", - "open_proposal_submission" + "submission_policy" ], "properties": { "deposit_info": { @@ -30,9 +30,13 @@ } ] }, - "open_proposal_submission": { - "description": "If false, only members (addresses with voting power) may create proposals in the DAO. Otherwise, any address may create a proposal so long as they pay the deposit.", - "type": "boolean" + "submission_policy": { + "description": "The policy dictating who is allowed to submit proposals.", + "allOf": [ + { + "$ref": "#/definitions/PreProposeSubmissionPolicy" + } + ] } }, "additionalProperties": false, @@ -115,6 +119,80 @@ "description": "An empty struct that serves as a placeholder in different places, such as contracts that don't set a custom message.\n\nIt is designed to be expressable in correct JSON and JSON Schema but contains no meaningful data. Previously we used enums without cases, but those cannot represented as valid JSON Schema (https://github.com/CosmWasm/cosmwasm/issues/451)", "type": "object" }, + "PreProposeSubmissionPolicy": { + "description": "The policy configured in a pre-propose module that determines who can submit proposals. This is the preferred way to restrict proposal creation (as opposed to the ProposalCreationPolicy above) since pre-propose modules support other features, such as proposal deposits.", + "oneOf": [ + { + "description": "Anyone may create proposals, except for those in the denylist.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "properties": { + "denylist": { + "description": "Addresses that may not create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Specific people may create proposals.", + "type": "object", + "required": [ + "specific" + ], + "properties": { + "specific": { + "type": "object", + "required": [ + "dao_members" + ], + "properties": { + "allowlist": { + "description": "Addresses that may create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "dao_members": { + "description": "Whether or not DAO members may create proposals.", + "type": "boolean" + }, + "denylist": { + "description": "Addresses that may not create proposals, overriding other settings.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "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" @@ -230,11 +308,9 @@ "properties": { "update_config": { "type": "object", - "required": [ - "open_proposal_submission" - ], "properties": { "deposit_info": { + "description": "If None, will remove the deposit. Backwards compatible.", "anyOf": [ { "$ref": "#/definitions/UncheckedDepositInfo" @@ -244,8 +320,79 @@ } ] }, - "open_proposal_submission": { - "type": "boolean" + "submission_policy": { + "description": "If None, will leave the submission policy in the config as-is.", + "anyOf": [ + { + "$ref": "#/definitions/PreProposeSubmissionPolicy" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Perform more granular submission policy updates to allow for atomic operations that don't override others.", + "type": "object", + "required": [ + "update_submission_policy" + ], + "properties": { + "update_submission_policy": { + "type": "object", + "properties": { + "allowlist_add": { + "description": "If using specific policy, optionally add to the allowlist.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "allowlist_remove": { + "description": "If using specific policy, optionally remove from the allowlist.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "denylist_add": { + "description": "Optionally add to the denylist. Works for any submission policy.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "denylist_remove": { + "description": "Optionally remove from denylist. Works for any submission policy.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "set_dao_members": { + "description": "If using specific policy, optionally update the `dao_members` flag.", + "type": [ + "boolean", + "null" + ] } }, "additionalProperties": false @@ -1008,6 +1155,80 @@ }, "additionalProperties": false }, + "PreProposeSubmissionPolicy": { + "description": "The policy configured in a pre-propose module that determines who can submit proposals. This is the preferred way to restrict proposal creation (as opposed to the ProposalCreationPolicy above) since pre-propose modules support other features, such as proposal deposits.", + "oneOf": [ + { + "description": "Anyone may create proposals, except for those in the denylist.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "properties": { + "denylist": { + "description": "Addresses that may not create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Specific people may create proposals.", + "type": "object", + "required": [ + "specific" + ], + "properties": { + "specific": { + "type": "object", + "required": [ + "dao_members" + ], + "properties": { + "allowlist": { + "description": "Addresses that may create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "dao_members": { + "description": "Whether or not DAO members may create proposals.", + "type": "boolean" + }, + "denylist": { + "description": "Addresses that may not create proposals, overriding other settings.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, "ProposeMessage": { "oneOf": [ { @@ -1556,6 +1777,28 @@ }, "additionalProperties": false }, + { + "description": "Returns whether or not the address can submit proposals.", + "type": "object", + "required": [ + "can_propose" + ], + "properties": { + "can_propose": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, { "description": "Returns list of proposal submitted hooks.", "type": "object", @@ -1603,12 +1846,17 @@ "migrate": null, "sudo": null, "responses": { + "can_propose": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Boolean", + "type": "boolean" + }, "config": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Config", "type": "object", "required": [ - "open_proposal_submission" + "submission_policy" ], "properties": { "deposit_info": { @@ -1622,9 +1870,13 @@ } ] }, - "open_proposal_submission": { - "description": "If false, only members (addresses with voting power) may create proposals in the DAO. Otherwise, any address may create a proposal so long as they pay the deposit.", - "type": "boolean" + "submission_policy": { + "description": "The policy dictating who is allowed to submit proposals.", + "allOf": [ + { + "$ref": "#/definitions/PreProposeSubmissionPolicy" + } + ] } }, "additionalProperties": false, @@ -1725,6 +1977,80 @@ } ] }, + "PreProposeSubmissionPolicy": { + "description": "The policy configured in a pre-propose module that determines who can submit proposals. This is the preferred way to restrict proposal creation (as opposed to the ProposalCreationPolicy above) since pre-propose modules support other features, such as proposal deposits.", + "oneOf": [ + { + "description": "Anyone may create proposals, except for those in the denylist.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "properties": { + "denylist": { + "description": "Addresses that may not create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Specific people may create proposals.", + "type": "object", + "required": [ + "specific" + ], + "properties": { + "specific": { + "type": "object", + "required": [ + "dao_members" + ], + "properties": { + "allowlist": { + "description": "Addresses that may create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "dao_members": { + "description": "Whether or not DAO members may create proposals.", + "type": "boolean" + }, + "denylist": { + "description": "Addresses that may not create proposals, overriding other settings.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "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" diff --git a/contracts/pre-propose/dao-pre-propose-multiple/src/contract.rs b/contracts/pre-propose/dao-pre-propose-multiple/src/contract.rs index 30e119fbc..4c1d03126 100644 --- a/contracts/pre-propose/dao-pre-propose-multiple/src/contract.rs +++ b/contracts/pre-propose/dao-pre-propose-multiple/src/contract.rs @@ -87,10 +87,23 @@ pub fn execute( ExecuteMsg::Withdraw { denom } => ExecuteInternal::Withdraw { denom }, ExecuteMsg::UpdateConfig { deposit_info, - open_proposal_submission, + submission_policy, } => ExecuteInternal::UpdateConfig { deposit_info, - open_proposal_submission, + submission_policy, + }, + ExecuteMsg::UpdateSubmissionPolicy { + denylist_add, + denylist_remove, + set_dao_members, + allowlist_add, + allowlist_remove, + } => ExecuteInternal::UpdateSubmissionPolicy { + denylist_add, + denylist_remove, + set_dao_members, + allowlist_add, + allowlist_remove, }, ExecuteMsg::AddProposalSubmittedHook { address } => { ExecuteInternal::AddProposalSubmittedHook { address } diff --git a/contracts/pre-propose/dao-pre-propose-multiple/src/tests.rs b/contracts/pre-propose/dao-pre-propose-multiple/src/tests.rs index 56f310ee2..48a01a2aa 100644 --- a/contracts/pre-propose/dao-pre-propose-multiple/src/tests.rs +++ b/contracts/pre-propose/dao-pre-propose-multiple/src/tests.rs @@ -10,6 +10,7 @@ use dao_interface::state::{Admin, ModuleInstantiateInfo}; use dao_pre_propose_base::{error::PreProposeError, msg::DepositInfoResponse, state::Config}; use dao_proposal_multiple as cpm; use dao_testing::helpers::instantiate_with_cw4_groups_governance; +use dao_voting::pre_propose::{PreProposeSubmissionPolicy, PreProposeSubmissionPolicyError}; use dao_voting::{ deposit::{CheckedDepositInfo, DepositRefundPolicy, DepositToken, UncheckedDepositInfo}, multiple_choice::{ @@ -54,6 +55,16 @@ fn get_default_proposal_module_instantiate( ) -> cpm::msg::InstantiateMsg { let pre_propose_id = app.store_code(cw_pre_propose_base_proposal_single()); + let submission_policy = if open_proposal_submission { + PreProposeSubmissionPolicy::Anyone { denylist: None } + } else { + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + } + }; + cpm::msg::InstantiateMsg { voting_strategy: VotingStrategy::SingleChoice { quorum: PercentageThreshold::Percent(Decimal::percent(10)), @@ -67,7 +78,7 @@ fn get_default_proposal_module_instantiate( code_id: pre_propose_id, msg: to_json_binary(&InstantiateMsg { deposit_info, - open_proposal_submission, + submission_policy, extension: Empty::default(), }) .unwrap(), @@ -351,19 +362,30 @@ fn get_deposit_info(app: &App, module: Addr, id: u64) -> DepositInfoResponse { .unwrap() } +fn query_can_propose(app: &App, module: Addr, address: impl Into) -> bool { + app.wrap() + .query_wasm_smart( + module, + &QueryMsg::CanPropose { + address: address.into(), + }, + ) + .unwrap() +} + fn update_config( app: &mut App, module: Addr, sender: &str, deposit_info: Option, - open_proposal_submission: bool, + submission_policy: PreProposeSubmissionPolicy, ) -> Config { app.execute_contract( Addr::unchecked(sender), module.clone(), &ExecuteMsg::UpdateConfig { deposit_info, - open_proposal_submission, + submission_policy: Some(submission_policy), }, &[], ) @@ -377,14 +399,14 @@ fn update_config_should_fail( module: Addr, sender: &str, deposit_info: Option, - open_proposal_submission: bool, + submission_policy: PreProposeSubmissionPolicy, ) -> PreProposeError { app.execute_contract( Addr::unchecked(sender), module, &ExecuteMsg::UpdateConfig { deposit_info, - open_proposal_submission, + submission_policy: Some(submission_policy), }, &[], ) @@ -878,7 +900,10 @@ fn test_permissions() { .unwrap_err() .downcast() .unwrap(); - assert_eq!(err, PreProposeError::NotMember {}) + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ) } #[test] @@ -985,7 +1010,10 @@ fn test_no_deposit_required_members_submission() { .unwrap_err() .downcast() .unwrap(); - assert_eq!(err, PreProposeError::NotMember {}); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); let id = make_proposal(&mut app, pre_propose, proposal_single.clone(), "ekez", &[]); let new_status = vote( @@ -998,6 +1026,260 @@ fn test_no_deposit_required_members_submission() { assert_eq!(Status::Passed, new_status) } +#[test] +fn test_anyone_denylist() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } = setup_default_test(&mut app, None, false); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Anyone { denylist: None }, + ); + + let rando = "rando"; + + // Proposal succeeds when anyone can propose. + assert!(query_can_propose(&app, pre_propose.clone(), rando)); + make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + rando, + &[], + ); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Anyone { + denylist: Some(vec![rando.to_string()]), + }, + ); + + // Proposing fails if on denylist. + assert!(!query_can_propose(&app, pre_propose.clone(), rando)); + let err: PreProposeError = app + .execute_contract( + Addr::unchecked(rando), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "I would like to join the DAO".to_string(), + description: "though, I am currently not a member.".to_string(), + choices: MultipleChoiceOptions { + options: vec![MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }], + }, + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + // Proposing succeeds if not on denylist. + assert!(query_can_propose(&app, pre_propose.clone(), "ekez")); + make_proposal(&mut app, pre_propose, proposal_single.clone(), "ekez", &[]); +} + +#[test] +fn test_specific_allowlist_denylist() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } = setup_default_test(&mut app, None, false); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, + ); + + // Proposal succeeds for member. + assert!(query_can_propose(&app, pre_propose.clone(), "ekez")); + make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + "ekez", + &[], + ); + + let rando = "rando"; + + // Proposing fails for non-member. + assert!(!query_can_propose(&app, pre_propose.clone(), rando)); + let err: PreProposeError = app + .execute_contract( + Addr::unchecked(rando), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "I would like to join the DAO".to_string(), + description: "though, I am currently not a member.".to_string(), + choices: MultipleChoiceOptions { + options: vec![MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }], + }, + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: Some(vec![rando.to_string()]), + denylist: None, + }, + ); + + // Proposal succeeds if on allowlist. + assert!(query_can_propose(&app, pre_propose.clone(), rando)); + make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + rando, + &[], + ); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: Some(vec![rando.to_string()]), + denylist: Some(vec!["ekez".to_string()]), + }, + ); + + // Proposing fails if on denylist. + assert!(!query_can_propose(&app, pre_propose.clone(), "ekez")); + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("ekez"), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "Let me propose!".to_string(), + description: "I am a member!!!".to_string(), + choices: MultipleChoiceOptions { + options: vec![MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }], + }, + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: Some(vec![rando.to_string()]), + denylist: None, + }, + ); + + // Proposing fails if members not allowed. + assert!(!query_can_propose(&app, pre_propose.clone(), "ekez")); + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("ekez"), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "Let me propose!".to_string(), + description: "I am a member!!!".to_string(), + choices: MultipleChoiceOptions { + options: vec![MultipleChoiceOption { + description: "multiple choice option 1".to_string(), + msgs: vec![], + title: "title".to_string(), + }], + }, + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + // Proposal succeeds if on allowlist. + assert!(query_can_propose(&app, pre_propose.clone(), rando)); + make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + rando, + &[], + ); +} + #[test] fn test_execute_extension_does_nothing() { let mut app = App::default(); @@ -1059,7 +1341,11 @@ fn test_instantiate_with_zero_native_deposit() { amount: Uint128::zero(), refund_policy: DepositRefundPolicy::OnlyPassed, }), - open_proposal_submission: false, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, extension: Empty::default(), }) .unwrap(), @@ -1122,7 +1408,11 @@ fn test_instantiate_with_zero_cw20_deposit() { amount: Uint128::zero(), refund_policy: DepositRefundPolicy::OnlyPassed, }), - open_proposal_submission: false, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, extension: Empty::default(), }) .unwrap(), @@ -1168,7 +1458,11 @@ fn test_update_config() { config, Config { deposit_info: None, - open_proposal_submission: false + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None + } } ); @@ -1191,7 +1485,7 @@ fn test_update_config() { amount: Uint128::new(10), refund_policy: DepositRefundPolicy::Never, }), - true, + PreProposeSubmissionPolicy::Anyone { denylist: None }, ); let config = get_config(&app, pre_propose.clone()); @@ -1203,7 +1497,7 @@ fn test_update_config() { amount: Uint128::new(10), refund_policy: DepositRefundPolicy::Never }), - open_proposal_submission: true, + submission_policy: PreProposeSubmissionPolicy::Anyone { denylist: None }, } ); @@ -1261,9 +1555,503 @@ fn test_update_config() { assert_eq!(balance, Uint128::new(0)); // Only the core module can update the config. - let err = - update_config_should_fail(&mut app, pre_propose, proposal_single.as_str(), None, true); + let err = update_config_should_fail( + &mut app, + pre_propose.clone(), + proposal_single.as_str(), + None, + PreProposeSubmissionPolicy::Anyone { denylist: None }, + ); assert_eq!(err, PreProposeError::NotDao {}); + + // Errors when no one is authorized to create proposals. + let err = update_config_should_fail( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: None, + denylist: None, + }, + ); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::NoOneAllowed {}) + ); + + // Errors when allowlist and denylist overlap. + let err = update_config_should_fail( + &mut app, + pre_propose, + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: Some(vec!["ekez".to_string()]), + denylist: Some(vec!["ekez".to_string()]), + }, + ); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::DenylistAllowlistOverlap {} + ) + ); +} + +#[test] +fn test_update_submission_policy() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr, + pre_propose, + .. + } = setup_default_test(&mut app, None, true); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { denylist: None }, + } + ); + + // Only the core module can update the submission policy. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("ekez"), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotDao {}); + + // Append to denylist, with auto de-dupe. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string(), "ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { + denylist: Some(vec!["ekez".to_string()]), + }, + } + ); + + // Add and remove to/from denylist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["someone".to_string(), "else".to_string()]), + denylist_remove: Some(vec!["ekez".to_string()]), + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { + denylist: Some(vec!["someone".to_string(), "else".to_string()]), + }, + } + ); + + // Remove from denylist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: Some(vec!["someone".to_string(), "else".to_string()]), + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { denylist: None }, + } + ); + + // Error if try to change Specific fields when set to Anyone. + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: Some(true), + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::AnyoneInvalidUpdateFields {} + ) + ); + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: Some(vec!["ekez".to_string()]), + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::AnyoneInvalidUpdateFields {} + ) + ); + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: Some(vec!["ekez".to_string()]), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::AnyoneInvalidUpdateFields {} + ) + ); + + // Change to Specific policy. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateConfig { + deposit_info: None, + submission_policy: Some(PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }), + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, + } + ); + + // Append to denylist, with auto de-dupe. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string(), "ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: Some(vec!["ekez".to_string()]), + }, + } + ); + + // Add and remove to/from denylist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["someone".to_string(), "else".to_string()]), + denylist_remove: Some(vec!["ekez".to_string()]), + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: Some(vec!["someone".to_string(), "else".to_string()]), + }, + } + ); + + // Remove from denylist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: Some(vec!["someone".to_string(), "else".to_string()]), + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None + }, + } + ); + + // Append to allowlist, with auto de-dupe. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: Some(vec!["ekez".to_string(), "ekez".to_string()]), + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: Some(vec!["ekez".to_string()]), + denylist: None, + }, + } + ); + + // Add and remove to/from allowlist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: Some(vec!["someone".to_string(), "else".to_string()]), + allowlist_remove: Some(vec!["ekez".to_string()]), + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: Some(vec!["someone".to_string(), "else".to_string()]), + denylist: None, + }, + } + ); + + // Remove from allowlist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: Some(vec!["someone".to_string(), "else".to_string()]), + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None + }, + } + ); + + // Setting dao_members to false fails if allowlist is empty. + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: Some(false), + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::NoOneAllowed {}) + ); + + // Set dao_members to false and add allowlist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: Some(false), + allowlist_add: Some(vec!["ekez".to_string()]), + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: Some(vec!["ekez".to_string()]), + denylist: None + }, + } + ); + + // Errors when allowlist and denylist overlap. + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::DenylistAllowlistOverlap {} + ) + ); } #[test] @@ -1307,7 +2095,11 @@ fn test_withdraw() { amount: Uint128::new(10), refund_policy: DepositRefundPolicy::Always, }), - false, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, ); // Withdraw with no specified denom - should fall back to the one @@ -1351,7 +2143,11 @@ fn test_withdraw() { amount: Uint128::new(10), refund_policy: DepositRefundPolicy::Always, }), - false, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, ); increase_allowance( diff --git a/contracts/pre-propose/dao-pre-propose-single/schema/dao-pre-propose-single.json b/contracts/pre-propose/dao-pre-propose-single/schema/dao-pre-propose-single.json index bf590180a..578fe5634 100644 --- a/contracts/pre-propose/dao-pre-propose-single/schema/dao-pre-propose-single.json +++ b/contracts/pre-propose/dao-pre-propose-single/schema/dao-pre-propose-single.json @@ -1,6 +1,6 @@ { "contract_name": "dao-pre-propose-single", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", @@ -8,7 +8,7 @@ "type": "object", "required": [ "extension", - "open_proposal_submission" + "submission_policy" ], "properties": { "deposit_info": { @@ -30,9 +30,13 @@ } ] }, - "open_proposal_submission": { - "description": "If false, only members (addresses with voting power) may create proposals in the DAO. Otherwise, any address may create a proposal so long as they pay the deposit.", - "type": "boolean" + "submission_policy": { + "description": "The policy dictating who is allowed to submit proposals.", + "allOf": [ + { + "$ref": "#/definitions/PreProposeSubmissionPolicy" + } + ] } }, "additionalProperties": false, @@ -115,6 +119,80 @@ "description": "An empty struct that serves as a placeholder in different places, such as contracts that don't set a custom message.\n\nIt is designed to be expressable in correct JSON and JSON Schema but contains no meaningful data. Previously we used enums without cases, but those cannot represented as valid JSON Schema (https://github.com/CosmWasm/cosmwasm/issues/451)", "type": "object" }, + "PreProposeSubmissionPolicy": { + "description": "The policy configured in a pre-propose module that determines who can submit proposals. This is the preferred way to restrict proposal creation (as opposed to the ProposalCreationPolicy above) since pre-propose modules support other features, such as proposal deposits.", + "oneOf": [ + { + "description": "Anyone may create proposals, except for those in the denylist.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "properties": { + "denylist": { + "description": "Addresses that may not create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Specific people may create proposals.", + "type": "object", + "required": [ + "specific" + ], + "properties": { + "specific": { + "type": "object", + "required": [ + "dao_members" + ], + "properties": { + "allowlist": { + "description": "Addresses that may create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "dao_members": { + "description": "Whether or not DAO members may create proposals.", + "type": "boolean" + }, + "denylist": { + "description": "Addresses that may not create proposals, overriding other settings.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "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" @@ -230,11 +308,9 @@ "properties": { "update_config": { "type": "object", - "required": [ - "open_proposal_submission" - ], "properties": { "deposit_info": { + "description": "If None, will remove the deposit. Backwards compatible.", "anyOf": [ { "$ref": "#/definitions/UncheckedDepositInfo" @@ -244,8 +320,79 @@ } ] }, - "open_proposal_submission": { - "type": "boolean" + "submission_policy": { + "description": "If None, will leave the submission policy in the config as-is.", + "anyOf": [ + { + "$ref": "#/definitions/PreProposeSubmissionPolicy" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Perform more granular submission policy updates to allow for atomic operations that don't override others.", + "type": "object", + "required": [ + "update_submission_policy" + ], + "properties": { + "update_submission_policy": { + "type": "object", + "properties": { + "allowlist_add": { + "description": "If using specific policy, optionally add to the allowlist.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "allowlist_remove": { + "description": "If using specific policy, optionally remove from the allowlist.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "denylist_add": { + "description": "Optionally add to the denylist. Works for any submission policy.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "denylist_remove": { + "description": "Optionally remove from denylist. Works for any submission policy.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "set_dao_members": { + "description": "If using specific policy, optionally update the `dao_members` flag.", + "type": [ + "boolean", + "null" + ] } }, "additionalProperties": false @@ -929,6 +1076,80 @@ } } }, + "PreProposeSubmissionPolicy": { + "description": "The policy configured in a pre-propose module that determines who can submit proposals. This is the preferred way to restrict proposal creation (as opposed to the ProposalCreationPolicy above) since pre-propose modules support other features, such as proposal deposits.", + "oneOf": [ + { + "description": "Anyone may create proposals, except for those in the denylist.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "properties": { + "denylist": { + "description": "Addresses that may not create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Specific people may create proposals.", + "type": "object", + "required": [ + "specific" + ], + "properties": { + "specific": { + "type": "object", + "required": [ + "dao_members" + ], + "properties": { + "allowlist": { + "description": "Addresses that may create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "dao_members": { + "description": "Whether or not DAO members may create proposals.", + "type": "boolean" + }, + "denylist": { + "description": "Addresses that may not create proposals, overriding other settings.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, "ProposeMessage": { "oneOf": [ { @@ -1530,6 +1751,28 @@ }, "additionalProperties": false }, + { + "description": "Returns whether or not the address can submit proposals.", + "type": "object", + "required": [ + "can_propose" + ], + "properties": { + "can_propose": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, { "description": "Returns list of proposal submitted hooks.", "type": "object", @@ -1577,12 +1820,17 @@ "migrate": null, "sudo": null, "responses": { + "can_propose": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Boolean", + "type": "boolean" + }, "config": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Config", "type": "object", "required": [ - "open_proposal_submission" + "submission_policy" ], "properties": { "deposit_info": { @@ -1596,9 +1844,13 @@ } ] }, - "open_proposal_submission": { - "description": "If false, only members (addresses with voting power) may create proposals in the DAO. Otherwise, any address may create a proposal so long as they pay the deposit.", - "type": "boolean" + "submission_policy": { + "description": "The policy dictating who is allowed to submit proposals.", + "allOf": [ + { + "$ref": "#/definitions/PreProposeSubmissionPolicy" + } + ] } }, "additionalProperties": false, @@ -1699,6 +1951,80 @@ } ] }, + "PreProposeSubmissionPolicy": { + "description": "The policy configured in a pre-propose module that determines who can submit proposals. This is the preferred way to restrict proposal creation (as opposed to the ProposalCreationPolicy above) since pre-propose modules support other features, such as proposal deposits.", + "oneOf": [ + { + "description": "Anyone may create proposals, except for those in the denylist.", + "type": "object", + "required": [ + "anyone" + ], + "properties": { + "anyone": { + "type": "object", + "properties": { + "denylist": { + "description": "Addresses that may not create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Specific people may create proposals.", + "type": "object", + "required": [ + "specific" + ], + "properties": { + "specific": { + "type": "object", + "required": [ + "dao_members" + ], + "properties": { + "allowlist": { + "description": "Addresses that may create proposals.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "dao_members": { + "description": "Whether or not DAO members may create proposals.", + "type": "boolean" + }, + "denylist": { + "description": "Addresses that may not create proposals, overriding other settings.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "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" diff --git a/contracts/pre-propose/dao-pre-propose-single/src/contract.rs b/contracts/pre-propose/dao-pre-propose-single/src/contract.rs index 73b283cac..9f65d7f06 100644 --- a/contracts/pre-propose/dao-pre-propose-single/src/contract.rs +++ b/contracts/pre-propose/dao-pre-propose-single/src/contract.rs @@ -91,10 +91,23 @@ pub fn execute( ExecuteMsg::Withdraw { denom } => ExecuteInternal::Withdraw { denom }, ExecuteMsg::UpdateConfig { deposit_info, - open_proposal_submission, + submission_policy, } => ExecuteInternal::UpdateConfig { deposit_info, - open_proposal_submission, + submission_policy, + }, + ExecuteMsg::UpdateSubmissionPolicy { + denylist_add, + denylist_remove, + set_dao_members, + allowlist_add, + allowlist_remove, + } => ExecuteInternal::UpdateSubmissionPolicy { + denylist_add, + denylist_remove, + set_dao_members, + allowlist_add, + allowlist_remove, }, ExecuteMsg::AddProposalSubmittedHook { address } => { ExecuteInternal::AddProposalSubmittedHook { address } diff --git a/contracts/pre-propose/dao-pre-propose-single/src/tests.rs b/contracts/pre-propose/dao-pre-propose-single/src/tests.rs index 0475d13b6..f579ad398 100644 --- a/contracts/pre-propose/dao-pre-propose-single/src/tests.rs +++ b/contracts/pre-propose/dao-pre-propose-single/src/tests.rs @@ -9,6 +9,7 @@ use dao_interface::state::{Admin, ModuleInstantiateInfo}; use dao_pre_propose_base::{error::PreProposeError, msg::DepositInfoResponse, state::Config}; use dao_proposal_single as dps; use dao_testing::helpers::instantiate_with_cw4_groups_governance; +use dao_voting::pre_propose::{PreProposeSubmissionPolicy, PreProposeSubmissionPolicyError}; use dao_voting::{ deposit::{CheckedDepositInfo, DepositRefundPolicy, DepositToken, UncheckedDepositInfo}, pre_propose::{PreProposeInfo, ProposalCreationPolicy}, @@ -52,6 +53,16 @@ fn get_default_proposal_module_instantiate( ) -> dps::msg::InstantiateMsg { let pre_propose_id = app.store_code(cw_pre_propose_base_proposal_single()); + let submission_policy = if open_proposal_submission { + PreProposeSubmissionPolicy::Anyone { denylist: None } + } else { + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + } + }; + dps::msg::InstantiateMsg { threshold: Threshold::AbsolutePercentage { percentage: PercentageThreshold::Majority {}, @@ -65,7 +76,7 @@ fn get_default_proposal_module_instantiate( code_id: pre_propose_id, msg: to_json_binary(&InstantiateMsg { deposit_info, - open_proposal_submission, + submission_policy, extension: Empty::default(), }) .unwrap(), @@ -332,19 +343,30 @@ fn get_deposit_info(app: &App, module: Addr, id: u64) -> DepositInfoResponse { .unwrap() } +fn query_can_propose(app: &App, module: Addr, address: impl Into) -> bool { + app.wrap() + .query_wasm_smart( + module, + &QueryMsg::CanPropose { + address: address.into(), + }, + ) + .unwrap() +} + fn update_config( app: &mut App, module: Addr, sender: &str, deposit_info: Option, - open_proposal_submission: bool, + submission_policy: PreProposeSubmissionPolicy, ) -> Config { app.execute_contract( Addr::unchecked(sender), module.clone(), &ExecuteMsg::UpdateConfig { deposit_info, - open_proposal_submission, + submission_policy: Some(submission_policy), }, &[], ) @@ -358,14 +380,14 @@ fn update_config_should_fail( module: Addr, sender: &str, deposit_info: Option, - open_proposal_submission: bool, + submission_policy: PreProposeSubmissionPolicy, ) -> PreProposeError { app.execute_contract( Addr::unchecked(sender), module, &ExecuteMsg::UpdateConfig { deposit_info, - open_proposal_submission, + submission_policy: Some(submission_policy), }, &[], ) @@ -838,7 +860,10 @@ fn test_permissions() { .unwrap_err() .downcast() .unwrap(); - assert_eq!(err, PreProposeError::NotMember {}) + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ) } #[test] @@ -927,13 +952,246 @@ fn test_no_deposit_required_members_submission() { .unwrap_err() .downcast() .unwrap(); - assert_eq!(err, PreProposeError::NotMember {}); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); let id = make_proposal(&mut app, pre_propose, proposal_single.clone(), "ekez", &[]); let new_status = vote(&mut app, proposal_single, "ekez", id, Vote::Yes); assert_eq!(Status::Passed, new_status) } +#[test] +fn test_anyone_denylist() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } = setup_default_test(&mut app, None, false); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Anyone { denylist: None }, + ); + + let rando = "rando"; + + // Proposal succeeds when anyone can propose. + assert!(query_can_propose(&app, pre_propose.clone(), rando)); + make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + rando, + &[], + ); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Anyone { + denylist: Some(vec![rando.to_string()]), + }, + ); + + // Proposing fails if on denylist. + assert!(!query_can_propose(&app, pre_propose.clone(), rando)); + let err: PreProposeError = app + .execute_contract( + Addr::unchecked(rando), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "I would like to join the DAO".to_string(), + description: "though, I am currently not a member.".to_string(), + msgs: vec![], + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + // Proposing succeeds if not on denylist. + assert!(query_can_propose(&app, pre_propose.clone(), "ekez")); + make_proposal(&mut app, pre_propose, proposal_single.clone(), "ekez", &[]); +} + +#[test] +fn test_specific_allowlist_denylist() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr, + proposal_single, + pre_propose, + } = setup_default_test(&mut app, None, false); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, + ); + + // Proposal succeeds for member. + assert!(query_can_propose(&app, pre_propose.clone(), "ekez")); + make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + "ekez", + &[], + ); + + let rando = "rando"; + + // Proposing fails for non-member. + assert!(!query_can_propose(&app, pre_propose.clone(), rando)); + let err: PreProposeError = app + .execute_contract( + Addr::unchecked(rando), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "I would like to join the DAO".to_string(), + description: "though, I am currently not a member.".to_string(), + msgs: vec![], + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: Some(vec![rando.to_string()]), + denylist: None, + }, + ); + + // Proposal succeeds if on allowlist. + assert!(query_can_propose(&app, pre_propose.clone(), rando)); + make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + rando, + &[], + ); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: Some(vec![rando.to_string()]), + denylist: Some(vec!["ekez".to_string()]), + }, + ); + + // Proposing fails if on denylist. + assert!(!query_can_propose(&app, pre_propose.clone(), "ekez")); + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("ekez"), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "Let me propose!".to_string(), + description: "I am a member!!!".to_string(), + msgs: vec![], + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + update_config( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: Some(vec![rando.to_string()]), + denylist: None, + }, + ); + + // Proposing fails if members not allowed. + assert!(!query_can_propose(&app, pre_propose.clone(), "ekez")); + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("ekez"), + pre_propose.clone(), + &ExecuteMsg::Propose { + msg: ProposeMessage::Propose { + title: "Let me propose!".to_string(), + description: "I am a member!!!".to_string(), + msgs: vec![], + vote: None, + }, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::Unauthorized {}) + ); + + // Proposal succeeds if on allowlist. + assert!(query_can_propose(&app, pre_propose.clone(), rando)); + make_proposal( + &mut app, + pre_propose.clone(), + proposal_single.clone(), + rando, + &[], + ); +} + #[test] fn test_execute_extension_does_nothing() { let mut app = App::default(); @@ -995,7 +1253,11 @@ fn test_instantiate_with_zero_native_deposit() { amount: Uint128::zero(), refund_policy: DepositRefundPolicy::OnlyPassed, }), - open_proposal_submission: false, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, extension: Empty::default(), }) .unwrap(), @@ -1058,7 +1320,11 @@ fn test_instantiate_with_zero_cw20_deposit() { amount: Uint128::zero(), refund_policy: DepositRefundPolicy::OnlyPassed, }), - open_proposal_submission: false, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, extension: Empty::default(), }) .unwrap(), @@ -1104,7 +1370,11 @@ fn test_update_config() { config, Config { deposit_info: None, - open_proposal_submission: false + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None + }, } ); @@ -1127,7 +1397,7 @@ fn test_update_config() { amount: Uint128::new(10), refund_policy: DepositRefundPolicy::Never, }), - true, + PreProposeSubmissionPolicy::Anyone { denylist: None }, ); let config = get_config(&app, pre_propose.clone()); @@ -1139,7 +1409,7 @@ fn test_update_config() { amount: Uint128::new(10), refund_policy: DepositRefundPolicy::Never }), - open_proposal_submission: true, + submission_policy: PreProposeSubmissionPolicy::Anyone { denylist: None }, } ); @@ -1185,9 +1455,524 @@ fn test_update_config() { assert_eq!(balance, Uint128::new(0)); // Only the core module can update the config. - let err = - update_config_should_fail(&mut app, pre_propose, proposal_single.as_str(), None, true); + let err = update_config_should_fail( + &mut app, + pre_propose.clone(), + proposal_single.as_str(), + None, + PreProposeSubmissionPolicy::Anyone { denylist: None }, + ); assert_eq!(err, PreProposeError::NotDao {}); + + // Errors when no one is authorized to create proposals. + let err = update_config_should_fail( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: None, + denylist: None, + }, + ); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::NoOneAllowed {}) + ); + + // Errors when allowlist and denylist overlap. + let err = update_config_should_fail( + &mut app, + pre_propose.clone(), + core_addr.as_str(), + None, + PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: Some(vec!["ekez".to_string()]), + denylist: Some(vec!["ekez".to_string()]), + }, + ); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::DenylistAllowlistOverlap {} + ) + ); + + // Doesn't change submission policy if omitted. + app.execute_contract( + core_addr, + pre_propose.clone(), + &ExecuteMsg::UpdateConfig { + deposit_info: None, + submission_policy: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { denylist: None }, + } + ); +} + +#[test] +fn test_update_submission_policy() { + let mut app = App::default(); + let DefaultTestSetup { + core_addr, + pre_propose, + .. + } = setup_default_test(&mut app, None, true); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { denylist: None }, + } + ); + + // Only the core module can update the submission policy. + let err: PreProposeError = app + .execute_contract( + Addr::unchecked("ekez"), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, PreProposeError::NotDao {}); + + // Append to denylist, with auto de-dupe. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string(), "ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { + denylist: Some(vec!["ekez".to_string()]), + }, + } + ); + + // Add and remove to/from denylist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["someone".to_string(), "else".to_string()]), + denylist_remove: Some(vec!["ekez".to_string()]), + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { + denylist: Some(vec!["someone".to_string(), "else".to_string()]), + }, + } + ); + + // Remove from denylist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: Some(vec!["someone".to_string(), "else".to_string()]), + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Anyone { denylist: None }, + } + ); + + // Error if try to change Specific fields when set to Anyone. + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: Some(true), + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::AnyoneInvalidUpdateFields {} + ) + ); + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: Some(vec!["ekez".to_string()]), + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::AnyoneInvalidUpdateFields {} + ) + ); + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: Some(vec!["ekez".to_string()]), + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::AnyoneInvalidUpdateFields {} + ) + ); + + // Change to Specific policy. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateConfig { + deposit_info: None, + submission_policy: Some(PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }), + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, + } + ); + + // Append to denylist, with auto de-dupe. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string(), "ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: Some(vec!["ekez".to_string()]), + }, + } + ); + + // Add and remove to/from denylist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["someone".to_string(), "else".to_string()]), + denylist_remove: Some(vec!["ekez".to_string()]), + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: Some(vec!["someone".to_string(), "else".to_string()]), + }, + } + ); + + // Remove from denylist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: Some(vec!["someone".to_string(), "else".to_string()]), + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None + }, + } + ); + + // Append to allowlist, with auto de-dupe. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: Some(vec!["ekez".to_string(), "ekez".to_string()]), + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: Some(vec!["ekez".to_string()]), + denylist: None, + }, + } + ); + + // Add and remove to/from allowlist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: Some(vec!["someone".to_string(), "else".to_string()]), + allowlist_remove: Some(vec!["ekez".to_string()]), + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: Some(vec!["someone".to_string(), "else".to_string()]), + denylist: None, + }, + } + ); + + // Remove from allowlist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: Some(vec!["someone".to_string(), "else".to_string()]), + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None + }, + } + ); + + // Setting dao_members to false fails if allowlist is empty. + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: Some(false), + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy(PreProposeSubmissionPolicyError::NoOneAllowed {}) + ); + + // Set dao_members to false and add allowlist. + app.execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: None, + denylist_remove: None, + set_dao_members: Some(false), + allowlist_add: Some(vec!["ekez".to_string()]), + allowlist_remove: None, + }, + &[], + ) + .unwrap(); + + let config = get_config(&app, pre_propose.clone()); + assert_eq!( + config, + Config { + deposit_info: None, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: false, + allowlist: Some(vec!["ekez".to_string()]), + denylist: None + }, + } + ); + + // Errors when allowlist and denylist overlap. + let err: PreProposeError = app + .execute_contract( + core_addr.clone(), + pre_propose.clone(), + &ExecuteMsg::UpdateSubmissionPolicy { + denylist_add: Some(vec!["ekez".to_string()]), + denylist_remove: None, + set_dao_members: None, + allowlist_add: None, + allowlist_remove: None, + }, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::DenylistAllowlistOverlap {} + ) + ); } #[test] @@ -1231,7 +2016,11 @@ fn test_withdraw() { amount: Uint128::new(10), refund_policy: DepositRefundPolicy::Always, }), - false, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, ); // Withdraw with no specified denom - should fall back to the one @@ -1275,7 +2064,11 @@ fn test_withdraw() { amount: Uint128::new(10), refund_policy: DepositRefundPolicy::Always, }), - false, + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, ); increase_allowance( diff --git a/contracts/proposal/dao-proposal-condorcet/schema/dao-proposal-condorcet.json b/contracts/proposal/dao-proposal-condorcet/schema/dao-proposal-condorcet.json index 1ffbbb163..ab82db9c5 100644 --- a/contracts/proposal/dao-proposal-condorcet/schema/dao-proposal-condorcet.json +++ b/contracts/proposal/dao-proposal-condorcet/schema/dao-proposal-condorcet.json @@ -1,6 +1,6 @@ { "contract_name": "dao-proposal-condorcet", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/proposal/dao-proposal-multiple/schema/dao-proposal-multiple.json b/contracts/proposal/dao-proposal-multiple/schema/dao-proposal-multiple.json index 415412ea2..5f6a884d0 100644 --- a/contracts/proposal/dao-proposal-multiple/schema/dao-proposal-multiple.json +++ b/contracts/proposal/dao-proposal-multiple/schema/dao-proposal-multiple.json @@ -1,6 +1,6 @@ { "contract_name": "dao-proposal-multiple", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", @@ -5173,6 +5173,7 @@ "proposal_creation_policy": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "ProposalCreationPolicy", + "description": "The policy configured in a proposal module that determines whether or not a pre-propose module is in use. If so, only the module can create new proposals. Otherwise, there is no restriction on proposal creation.", "oneOf": [ { "description": "Anyone may create a proposal, free of charge.", diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/instantiate.rs b/contracts/proposal/dao-proposal-multiple/src/testing/instantiate.rs index 8fd7e0c95..28fdc5847 100644 --- a/contracts/proposal/dao-proposal-multiple/src/testing/instantiate.rs +++ b/contracts/proposal/dao-proposal-multiple/src/testing/instantiate.rs @@ -12,8 +12,11 @@ use dao_testing::contracts::{ use dao_voting::{ deposit::{DepositRefundPolicy, UncheckedDepositInfo, VotingModuleTokenType}, multiple_choice::VotingStrategy, - pre_propose::PreProposeInfo, - threshold::{ActiveThreshold, ActiveThreshold::AbsoluteCount, PercentageThreshold}, + pre_propose::{PreProposeInfo, PreProposeSubmissionPolicy}, + threshold::{ + ActiveThreshold::{self, AbsoluteCount}, + PercentageThreshold, + }, }; use dao_voting_cw4::msg::GroupContract; @@ -29,12 +32,23 @@ fn get_pre_propose_info( open_proposal_submission: bool, ) -> PreProposeInfo { let pre_propose_contract = app.store_code(pre_propose_multiple_contract()); + + let submission_policy = if open_proposal_submission { + PreProposeSubmissionPolicy::Anyone { denylist: None } + } else { + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + } + }; + PreProposeInfo::ModuleMayPropose { info: ModuleInstantiateInfo { code_id: pre_propose_contract, msg: to_json_binary(&cppm::InstantiateMsg { deposit_info, - open_proposal_submission, + submission_policy, extension: Empty::default(), }) .unwrap(), diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs b/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs index d7b51beaa..55b43cc9f 100644 --- a/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs +++ b/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs @@ -9,6 +9,7 @@ use cw_utils::Duration; use dao_interface::state::ProposalModule; use dao_interface::state::{Admin, ModuleInstantiateInfo}; use dao_voting::multiple_choice::MultipleChoiceAutoVote; +use dao_voting::pre_propose::PreProposeSubmissionPolicy; use dao_voting::veto::{VetoConfig, VetoError}; use dao_voting::{ deposit::{ @@ -96,12 +97,23 @@ pub fn get_pre_propose_info( open_proposal_submission: bool, ) -> PreProposeInfo { let pre_propose_contract = app.store_code(pre_propose_multiple_contract()); + + let submission_policy = if open_proposal_submission { + PreProposeSubmissionPolicy::Anyone { denylist: None } + } else { + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + } + }; + PreProposeInfo::ModuleMayPropose { info: ModuleInstantiateInfo { code_id: pre_propose_contract, msg: to_json_binary(&cppm::InstantiateMsg { deposit_info, - open_proposal_submission, + submission_policy, extension: Empty::default(), }) .unwrap(), diff --git a/contracts/proposal/dao-proposal-single/schema/dao-proposal-single.json b/contracts/proposal/dao-proposal-single/schema/dao-proposal-single.json index 2491d27aa..0d247d3cc 100644 --- a/contracts/proposal/dao-proposal-single/schema/dao-proposal-single.json +++ b/contracts/proposal/dao-proposal-single/schema/dao-proposal-single.json @@ -1,6 +1,6 @@ { "contract_name": "dao-proposal-single", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", @@ -5326,6 +5326,7 @@ "proposal_creation_policy": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "ProposalCreationPolicy", + "description": "The policy configured in a proposal module that determines whether or not a pre-propose module is in use. If so, only the module can create new proposals. Otherwise, there is no restriction on proposal creation.", "oneOf": [ { "description": "Anyone may create a proposal, free of charge.", diff --git a/contracts/proposal/dao-proposal-single/src/testing/instantiate.rs b/contracts/proposal/dao-proposal-single/src/testing/instantiate.rs index 020154700..42514753d 100644 --- a/contracts/proposal/dao-proposal-single/src/testing/instantiate.rs +++ b/contracts/proposal/dao-proposal-single/src/testing/instantiate.rs @@ -8,7 +8,7 @@ use dao_pre_propose_single as cppbps; use dao_voting::{ deposit::{DepositRefundPolicy, UncheckedDepositInfo, VotingModuleTokenType}, - pre_propose::PreProposeInfo, + pre_propose::{PreProposeInfo, PreProposeSubmissionPolicy}, threshold::{ActiveThreshold, PercentageThreshold, Threshold::ThresholdQuorum}, }; use dao_voting_cw4::msg::GroupContract; @@ -31,12 +31,23 @@ pub(crate) fn get_pre_propose_info( ) -> PreProposeInfo { let pre_propose_contract = app.store_code(crate::testing::contracts::pre_propose_single_contract()); + + let submission_policy = if open_proposal_submission { + PreProposeSubmissionPolicy::Anyone { denylist: None } + } else { + PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + } + }; + PreProposeInfo::ModuleMayPropose { info: ModuleInstantiateInfo { code_id: pre_propose_contract, msg: to_json_binary(&cppbps::InstantiateMsg { deposit_info, - open_proposal_submission, + submission_policy, extension: Empty::default(), }) .unwrap(), diff --git a/contracts/proposal/dao-proposal-single/src/testing/tests.rs b/contracts/proposal/dao-proposal-single/src/testing/tests.rs index 4246e5a09..34b591ea0 100644 --- a/contracts/proposal/dao-proposal-single/src/testing/tests.rs +++ b/contracts/proposal/dao-proposal-single/src/testing/tests.rs @@ -19,7 +19,7 @@ use dao_interface::{ use dao_testing::{ShouldExecute, TestSingleChoiceVote}; use dao_voting::{ deposit::{CheckedDepositInfo, UncheckedDepositInfo, VotingModuleTokenType}, - pre_propose::{PreProposeInfo, ProposalCreationPolicy}, + pre_propose::{PreProposeInfo, PreProposeSubmissionPolicy, ProposalCreationPolicy}, proposal::{SingleChoiceProposeMsg as ProposeMsg, MAX_PROPOSAL_SIZE}, reply::{ failed_pre_propose_module_hook_id, mask_proposal_execution_proposal_id, @@ -3955,7 +3955,11 @@ fn test_update_pre_propose_module() { amount: Uint128::new(1), refund_policy: dao_voting::deposit::DepositRefundPolicy::OnlyPassed, }), - open_proposal_submission: false, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None, + }, extension: Empty::default(), }) .unwrap(), @@ -4006,7 +4010,11 @@ fn test_update_pre_propose_module() { amount: Uint128::new(1), refund_policy: dao_voting::deposit::DepositRefundPolicy::OnlyPassed, }), - open_proposal_submission: false, + submission_policy: PreProposeSubmissionPolicy::Specific { + dao_members: true, + allowlist: None, + denylist: None + }, } ); diff --git a/contracts/staking/cw20-stake-external-rewards/schema/cw20-stake-external-rewards.json b/contracts/staking/cw20-stake-external-rewards/schema/cw20-stake-external-rewards.json index cab9e7c5b..aeec250c4 100644 --- a/contracts/staking/cw20-stake-external-rewards/schema/cw20-stake-external-rewards.json +++ b/contracts/staking/cw20-stake-external-rewards/schema/cw20-stake-external-rewards.json @@ -1,6 +1,6 @@ { "contract_name": "cw20-stake-external-rewards", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/staking/cw20-stake-reward-distributor/schema/cw20-stake-reward-distributor.json b/contracts/staking/cw20-stake-reward-distributor/schema/cw20-stake-reward-distributor.json index bbbd6279b..3acae01e3 100644 --- a/contracts/staking/cw20-stake-reward-distributor/schema/cw20-stake-reward-distributor.json +++ b/contracts/staking/cw20-stake-reward-distributor/schema/cw20-stake-reward-distributor.json @@ -1,6 +1,6 @@ { "contract_name": "cw20-stake-reward-distributor", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/staking/cw20-stake/schema/cw20-stake.json b/contracts/staking/cw20-stake/schema/cw20-stake.json index 5c04738ae..77c19cd17 100644 --- a/contracts/staking/cw20-stake/schema/cw20-stake.json +++ b/contracts/staking/cw20-stake/schema/cw20-stake.json @@ -1,6 +1,6 @@ { "contract_name": "cw20-stake", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", 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..17afb6f5c 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 @@ -1,6 +1,6 @@ { "contract_name": "dao-voting-cw20-staked", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/voting/dao-voting-cw4/schema/dao-voting-cw4.json b/contracts/voting/dao-voting-cw4/schema/dao-voting-cw4.json index f5c5a14ec..d8908601f 100644 --- a/contracts/voting/dao-voting-cw4/schema/dao-voting-cw4.json +++ b/contracts/voting/dao-voting-cw4/schema/dao-voting-cw4.json @@ -1,6 +1,6 @@ { "contract_name": "dao-voting-cw4", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/voting/dao-voting-cw721-roles/schema/dao-voting-cw721-roles.json b/contracts/voting/dao-voting-cw721-roles/schema/dao-voting-cw721-roles.json index be8df0318..71f8d7c38 100644 --- a/contracts/voting/dao-voting-cw721-roles/schema/dao-voting-cw721-roles.json +++ b/contracts/voting/dao-voting-cw721-roles/schema/dao-voting-cw721-roles.json @@ -1,6 +1,6 @@ { "contract_name": "dao-voting-cw721-roles", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/voting/dao-voting-cw721-staked/schema/dao-voting-cw721-staked.json b/contracts/voting/dao-voting-cw721-staked/schema/dao-voting-cw721-staked.json index d131f5109..aae82a3f8 100644 --- a/contracts/voting/dao-voting-cw721-staked/schema/dao-voting-cw721-staked.json +++ b/contracts/voting/dao-voting-cw721-staked/schema/dao-voting-cw721-staked.json @@ -1,6 +1,6 @@ { "contract_name": "dao-voting-cw721-staked", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/contracts/voting/dao-voting-token-staked/schema/dao-voting-token-staked.json b/contracts/voting/dao-voting-token-staked/schema/dao-voting-token-staked.json index 43c934c01..8f6c9aaba 100644 --- a/contracts/voting/dao-voting-token-staked/schema/dao-voting-token-staked.json +++ b/contracts/voting/dao-voting-token-staked/schema/dao-voting-token-staked.json @@ -1,6 +1,6 @@ { "contract_name": "dao-voting-token-staked", - "contract_version": "2.4.2", + "contract_version": "2.5.0", "idl_version": "1.0.0", "instantiate": { "$schema": "http://json-schema.org/draft-07/schema#", diff --git a/packages/dao-pre-propose-base/src/error.rs b/packages/dao-pre-propose-base/src/error.rs index 127996166..762ffa768 100644 --- a/packages/dao-pre-propose-base/src/error.rs +++ b/packages/dao-pre-propose-base/src/error.rs @@ -4,7 +4,9 @@ use cw_utils::ParseReplyError; use thiserror::Error; use cw_hooks::HookError; -use dao_voting::{deposit::DepositError, status::Status}; +use dao_voting::{ + deposit::DepositError, pre_propose::PreProposeSubmissionPolicyError, status::Status, +}; #[derive(Error, Debug, PartialEq)] pub enum PreProposeError { @@ -23,15 +25,15 @@ pub enum PreProposeError { #[error(transparent)] ParseReplyError(#[from] ParseReplyError), + #[error(transparent)] + SubmissionPolicy(#[from] PreProposeSubmissionPolicyError), + #[error("Message sender is not proposal module")] NotModule {}, #[error("Message sender is not dao")] NotDao {}, - #[error("You must be a member of this DAO (have voting power) to create a proposal")] - NotMember {}, - #[error("No denomination for withdrawal. specify a denomination to withdraw")] NoWithdrawalDenom {}, @@ -49,4 +51,7 @@ pub enum PreProposeError { #[error("An unknown reply ID was received.")] UnknownReplyID {}, + + #[error("Unsupported")] + Unsupported {}, } diff --git a/packages/dao-pre-propose-base/src/execute.rs b/packages/dao-pre-propose-base/src/execute.rs index 64e0ed3d6..223e08b2d 100644 --- a/packages/dao-pre-propose-base/src/execute.rs +++ b/packages/dao-pre-propose-base/src/execute.rs @@ -1,7 +1,7 @@ use cosmwasm_schema::schemars::JsonSchema; use cosmwasm_std::{ - to_json_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult, SubMsg, - WasmMsg, + to_json_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdError, StdResult, + SubMsg, WasmMsg, }; use cw2::set_contract_version; @@ -10,6 +10,7 @@ use cw_denom::UncheckedDenom; use dao_interface::voting::{Query as CwCoreQuery, VotingPowerAtHeightResponse}; use dao_voting::{ deposit::{DepositRefundPolicy, UncheckedDepositInfo}, + pre_propose::{PreProposeSubmissionPolicy, PreProposeSubmissionPolicyError}, status::Status, }; use serde::Serialize; @@ -56,9 +57,11 @@ where .map(|info| info.into_checked(deps.as_ref(), dao.clone())) .transpose()?; + msg.submission_policy.validate()?; + let config = Config { deposit_info, - open_proposal_submission: msg.open_proposal_submission, + submission_policy: msg.submission_policy, }; self.config.save(deps.storage, &config)?; @@ -68,8 +71,8 @@ where .add_attribute("proposal_module", info.sender.into_string()) .add_attribute("deposit_info", format!("{:?}", config.deposit_info)) .add_attribute( - "open_proposal_submission", - config.open_proposal_submission.to_string(), + "submission_policy", + config.submission_policy.human_readable(), ) .add_attribute("dao", dao)) } @@ -85,8 +88,23 @@ where ExecuteMsg::Propose { msg } => self.execute_propose(deps, env, info, msg), ExecuteMsg::UpdateConfig { deposit_info, - open_proposal_submission, - } => self.execute_update_config(deps, info, deposit_info, open_proposal_submission), + submission_policy, + } => self.execute_update_config(deps, info, deposit_info, submission_policy), + ExecuteMsg::UpdateSubmissionPolicy { + denylist_add, + denylist_remove, + set_dao_members, + allowlist_add, + allowlist_remove, + } => self.execute_update_submission_policy( + deps, + info, + denylist_add, + denylist_remove, + set_dao_members, + allowlist_add, + allowlist_remove, + ), ExecuteMsg::Withdraw { denom } => { self.execute_withdraw(deps.as_ref(), env, info, denom) } @@ -171,27 +189,186 @@ where deps: DepsMut, info: MessageInfo, deposit_info: Option, - open_proposal_submission: bool, + submission_policy: Option, ) -> Result { let dao = self.dao.load(deps.storage)?; if info.sender != dao { - Err(PreProposeError::NotDao {}) - } else { - let deposit_info = deposit_info - .map(|d| d.into_checked(deps.as_ref(), dao)) - .transpose()?; - self.config.save( - deps.storage, - &Config { + return Err(PreProposeError::NotDao {}); + } + + let deposit_info = deposit_info + .map(|d| d.into_checked(deps.as_ref(), dao)) + .transpose()?; + + self.config + .update(deps.storage, |prev| -> Result { + let new_submission_policy = if let Some(submission_policy) = submission_policy { + submission_policy.validate()?; + submission_policy + } else { + prev.submission_policy + }; + + Ok(Config { deposit_info, - open_proposal_submission, - }, - )?; + submission_policy: new_submission_policy, + }) + })?; - Ok(Response::default() - .add_attribute("method", "update_config") - .add_attribute("sender", info.sender)) + Ok(Response::default() + .add_attribute("method", "update_config") + .add_attribute("sender", info.sender)) + } + + #[allow(clippy::too_many_arguments)] + pub fn execute_update_submission_policy( + &self, + deps: DepsMut, + info: MessageInfo, + denylist_add: Option>, + denylist_remove: Option>, + set_dao_members: Option, + allowlist_add: Option>, + allowlist_remove: Option>, + ) -> Result { + let dao = self.dao.load(deps.storage)?; + if info.sender != dao { + return Err(PreProposeError::NotDao {}); } + + let mut config = self.config.load(deps.storage)?; + + match config.submission_policy { + PreProposeSubmissionPolicy::Anyone { denylist } => { + // Error if other values that apply to Specific were set. + if set_dao_members.is_some() + || allowlist_add.is_some() + || allowlist_remove.is_some() + { + return Err(PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::AnyoneInvalidUpdateFields {}, + )); + } + + let mut denylist = denylist.unwrap_or_default(); + + // Add to denylist. + if let Some(mut denylist_add) = denylist_add { + // Validate addresses. + denylist_add + .iter() + .map(|addr| deps.api.addr_validate(addr)) + .collect::>>()?; + + denylist.append(&mut denylist_add); + denylist.dedup(); + } + + // Remove from denylist. + if let Some(denylist_remove) = denylist_remove { + // Validate addresses. + denylist_remove + .iter() + .map(|addr| deps.api.addr_validate(addr)) + .collect::>>()?; + + denylist.retain(|a| !denylist_remove.contains(a)); + } + + let denylist = if denylist.is_empty() { + None + } else { + Some(denylist) + }; + + config.submission_policy = PreProposeSubmissionPolicy::Anyone { denylist }; + } + PreProposeSubmissionPolicy::Specific { + dao_members, + allowlist, + denylist, + } => { + let dao_members = if let Some(new_dao_members) = set_dao_members { + new_dao_members + } else { + dao_members + }; + + let mut allowlist = allowlist.unwrap_or_default(); + let mut denylist = denylist.unwrap_or_default(); + + // Add to allowlist. + if let Some(mut allowlist_add) = allowlist_add { + // Validate addresses. + allowlist_add + .iter() + .map(|addr| deps.api.addr_validate(addr)) + .collect::>>()?; + + allowlist.append(&mut allowlist_add); + allowlist.dedup(); + } + + // Remove from allowlist. + if let Some(allowlist_remove) = allowlist_remove { + // Validate addresses. + allowlist_remove + .iter() + .map(|addr| deps.api.addr_validate(addr)) + .collect::>>()?; + + allowlist.retain(|a| !allowlist_remove.contains(a)); + } + + // Add to denylist. + if let Some(mut denylist_add) = denylist_add { + // Validate addresses. + denylist_add + .iter() + .map(|addr| deps.api.addr_validate(addr)) + .collect::>>()?; + + denylist.append(&mut denylist_add); + denylist.dedup(); + } + + // Remove from denylist. + if let Some(denylist_remove) = denylist_remove { + // Validate addresses. + denylist_remove + .iter() + .map(|addr| deps.api.addr_validate(addr)) + .collect::>>()?; + + denylist.retain(|a| !denylist_remove.contains(a)); + } + + // Replace empty vectors with None. + let allowlist = if allowlist.is_empty() { + None + } else { + Some(allowlist) + }; + let denylist = if denylist.is_empty() { + None + } else { + Some(denylist) + }; + + config.submission_policy = PreProposeSubmissionPolicy::Specific { + dao_members, + allowlist, + denylist, + }; + } + } + + config.submission_policy.validate()?; + self.config.save(deps.storage, &config)?; + + Ok(Response::default() + .add_attribute("method", "update_submission_policy") + .add_attribute("sender", info.sender)) } pub fn execute_withdraw( @@ -341,20 +518,47 @@ where pub fn check_can_submit(&self, deps: Deps, who: Addr) -> Result<(), PreProposeError> { let config = self.config.load(deps.storage)?; - if !config.open_proposal_submission { - let dao = self.dao.load(deps.storage)?; - let voting_power: VotingPowerAtHeightResponse = deps.querier.query_wasm_smart( - dao.into_string(), - &CwCoreQuery::VotingPowerAtHeight { - address: who.into_string(), - height: None, - }, - )?; - if voting_power.power.is_zero() { - return Err(PreProposeError::NotMember {}); + match config.submission_policy { + PreProposeSubmissionPolicy::Anyone { denylist } => { + if !denylist.unwrap_or_default().contains(&who.to_string()) { + return Ok(()); + } + } + PreProposeSubmissionPolicy::Specific { + dao_members, + allowlist, + denylist, + } => { + // denylist overrides all other settings + if !denylist.unwrap_or_default().contains(&who.to_string()) { + // if on the allowlist, return early + if allowlist.unwrap_or_default().contains(&who.to_string()) { + return Ok(()); + } + + // check DAO membership only if not on the allowlist + if dao_members { + let dao = self.dao.load(deps.storage)?; + let voting_power: VotingPowerAtHeightResponse = + deps.querier.query_wasm_smart( + dao.into_string(), + &CwCoreQuery::VotingPowerAtHeight { + address: who.into_string(), + height: None, + }, + )?; + if !voting_power.power.is_zero() { + return Ok(()); + } + } + } } } - Ok(()) + + // all other cases are not allowed + Err(PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::Unauthorized {}, + )) } pub fn query(&self, deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { @@ -371,6 +575,22 @@ where proposer, }) } + QueryMsg::CanPropose { address } => { + let addr = deps.api.addr_validate(&address)?; + match self.check_can_submit(deps, addr) { + Ok(_) => to_json_binary(&true), + Err(err) => match err { + PreProposeError::SubmissionPolicy( + PreProposeSubmissionPolicyError::Unauthorized {}, + ) => to_json_binary(&false), + PreProposeError::Std(err) => Err(err), + _ => Err(StdError::generic_err(format!( + "unexpected error: {:?}", + err + ))), + }, + } + } QueryMsg::ProposalSubmittedHooks {} => { to_json_binary(&self.proposal_submitted_hooks.query_hooks(deps)?) } diff --git a/packages/dao-pre-propose-base/src/msg.rs b/packages/dao-pre-propose-base/src/msg.rs index b2f2cc410..f3391cf71 100644 --- a/packages/dao-pre-propose-base/src/msg.rs +++ b/packages/dao-pre-propose-base/src/msg.rs @@ -2,6 +2,7 @@ use cosmwasm_schema::{cw_serde, schemars::JsonSchema, QueryResponses}; use cw_denom::UncheckedDenom; use dao_voting::{ deposit::{CheckedDepositInfo, UncheckedDepositInfo}, + pre_propose::PreProposeSubmissionPolicy, status::Status, }; @@ -10,10 +11,8 @@ pub struct InstantiateMsg { /// Information about the deposit requirements for this /// module. None if no deposit. pub deposit_info: Option, - /// If false, only members (addresses with voting power) may create - /// proposals in the DAO. Otherwise, any address may create a - /// proposal so long as they pay the deposit. - pub open_proposal_submission: bool, + /// The policy dictating who is allowed to submit proposals. + pub submission_policy: PreProposeSubmissionPolicy, /// Extension for instantiation. The default implementation will /// do nothing with this data. pub extension: InstantiateExt, @@ -30,8 +29,25 @@ pub enum ExecuteMsg { /// will only apply to proposals created after the config is /// updated. Only the DAO may execute this message. UpdateConfig { + /// If None, will remove the deposit. Backwards compatible. deposit_info: Option, - open_proposal_submission: bool, + /// If None, will leave the submission policy in the config as-is. + submission_policy: Option, + }, + + /// Perform more granular submission policy updates to allow for atomic + /// operations that don't override others. + UpdateSubmissionPolicy { + /// Optionally add to the denylist. Works for any submission policy. + denylist_add: Option>, + /// Optionally remove from denylist. Works for any submission policy. + denylist_remove: Option>, + /// If using specific policy, optionally update the `dao_members` flag. + set_dao_members: Option, + /// If using specific policy, optionally add to the allowlist. + allowlist_add: Option>, + /// If using specific policy, optionally remove from the allowlist. + allowlist_remove: Option>, }, /// Withdraws funds inside of this contract to the message @@ -109,6 +125,9 @@ where /// PROPOSAL_ID. #[returns(DepositInfoResponse)] DepositInfo { proposal_id: u64 }, + /// Returns whether or not the address can submit proposals. + #[returns(bool)] + CanPropose { address: String }, /// Returns list of proposal submitted hooks. #[returns(cw_hooks::HooksResponse)] ProposalSubmittedHooks {}, diff --git a/packages/dao-pre-propose-base/src/state.rs b/packages/dao-pre-propose-base/src/state.rs index 26310a5cb..4967e1de9 100644 --- a/packages/dao-pre-propose-base/src/state.rs +++ b/packages/dao-pre-propose-base/src/state.rs @@ -5,17 +5,15 @@ use cosmwasm_std::Addr; use cw_hooks::Hooks; use cw_storage_plus::{Item, Map}; -use dao_voting::deposit::CheckedDepositInfo; +use dao_voting::{deposit::CheckedDepositInfo, pre_propose::PreProposeSubmissionPolicy}; #[cw_serde] pub struct Config { /// Information about the deposit required to create a /// proposal. If `None`, no deposit is required. pub deposit_info: Option, - /// If false, only members (addresses with voting power) may create - /// proposals in the DAO. Otherwise, any address may create a - /// proposal so long as they pay the deposit. - pub open_proposal_submission: bool, + /// The policy dictating who is allowed to submit proposals. + pub submission_policy: PreProposeSubmissionPolicy, } pub struct PreProposeContract { diff --git a/packages/dao-pre-propose-base/src/tests.rs b/packages/dao-pre-propose-base/src/tests.rs index a6b12a74d..89522def2 100644 --- a/packages/dao-pre-propose-base/src/tests.rs +++ b/packages/dao-pre-propose-base/src/tests.rs @@ -4,7 +4,7 @@ use cosmwasm_std::{ to_json_binary, Addr, Binary, ContractResult, Empty, Response, SubMsg, WasmMsg, }; use cw_hooks::HooksResponse; -use dao_voting::status::Status; +use dao_voting::{pre_propose::PreProposeSubmissionPolicy, status::Status}; use crate::{ error::PreProposeError, @@ -87,7 +87,7 @@ fn test_proposal_submitted_hooks() { &mut deps.storage, &Config { deposit_info: None, - open_proposal_submission: true, + submission_policy: PreProposeSubmissionPolicy::Anyone { denylist: None }, }, ) .unwrap(); diff --git a/packages/dao-voting/src/pre_propose.rs b/packages/dao-voting/src/pre_propose.rs index 482bf9260..680d1f588 100644 --- a/packages/dao-voting/src/pre_propose.rs +++ b/packages/dao-voting/src/pre_propose.rs @@ -4,6 +4,7 @@ use cosmwasm_schema::cw_serde; use cosmwasm_std::{Addr, Empty, StdResult, SubMsg}; use dao_interface::state::ModuleInstantiateInfo; +use thiserror::Error; use crate::reply::pre_propose_module_instantiation_id; @@ -16,6 +17,9 @@ pub enum PreProposeInfo { ModuleMayPropose { info: ModuleInstantiateInfo }, } +/// The policy configured in a proposal module that determines whether or not a +/// pre-propose module is in use. If so, only the module can create new +/// proposals. Otherwise, there is no restriction on proposal creation. #[cw_serde] pub enum ProposalCreationPolicy { /// Anyone may create a proposal, free of charge. @@ -58,6 +62,81 @@ impl PreProposeInfo { } } +/// The policy configured in a pre-propose module that determines who can submit +/// proposals. This is the preferred way to restrict proposal creation (as +/// opposed to the ProposalCreationPolicy above) since pre-propose modules +/// support other features, such as proposal deposits. +#[cw_serde] +pub enum PreProposeSubmissionPolicy { + /// Anyone may create proposals, except for those in the denylist. + Anyone { + /// Addresses that may not create proposals. + denylist: Option>, + }, + /// Specific people may create proposals. + Specific { + /// Whether or not DAO members may create proposals. + dao_members: bool, + /// Addresses that may create proposals. + allowlist: Option>, + /// Addresses that may not create proposals, overriding other settings. + denylist: Option>, + }, +} + +#[derive(Error, Debug, PartialEq, Eq)] +pub enum PreProposeSubmissionPolicyError { + #[error("The proposal submission policy doesn't allow anyone to submit proposals")] + NoOneAllowed {}, + + #[error("Denylist cannot contain addresses in the allowlist")] + DenylistAllowlistOverlap {}, + + #[error("You are not allowed to submit proposals")] + Unauthorized {}, + + #[error("The current proposal submission policy (Anyone) only supports a denylist. Change the policy to Specific in order to configure more granular permissions.")] + AnyoneInvalidUpdateFields {}, +} + +impl PreProposeSubmissionPolicy { + /// Validate the policy configuration. + pub fn validate(&self) -> Result<(), PreProposeSubmissionPolicyError> { + if let PreProposeSubmissionPolicy::Specific { + dao_members, + allowlist, + denylist, + } = self + { + let allowlist = allowlist.as_deref().unwrap_or_default(); + let denylist = denylist.as_deref().unwrap_or_default(); + + // prevent allowlist and denylist from overlapping + if denylist.iter().any(|a| allowlist.iter().any(|b| a == b)) { + return Err(PreProposeSubmissionPolicyError::DenylistAllowlistOverlap {}); + } + + // ensure someone is allowed to submit proposals, be it DAO members + // or someone on the allowlist. we can't verify that the denylist + // doesn't contain all DAO members, so this is the best we can do to + // ensure that someone is allowed to submit. + if !dao_members && allowlist.is_empty() { + return Err(PreProposeSubmissionPolicyError::NoOneAllowed {}); + } + } + + Ok(()) + } + + /// Human readable string for use in events. + pub fn human_readable(&self) -> String { + match self { + Self::Anyone { .. } => "anyone".to_string(), + Self::Specific { .. } => "specific".to_string(), + } + } +} + #[cfg(test)] mod tests { use cosmwasm_std::{to_json_binary, WasmMsg}; From 5918cbe10ef2cae71d1f15f2a98a2d14b6c0a950 Mon Sep 17 00:00:00 2001 From: noah Date: Mon, 8 Jul 2024 18:33:22 -0400 Subject: [PATCH 5/5] OmniFlix NFT (x/onft) staked voting module (#838) --- .github/workflows/codecov.yml | 41 +- Cargo.lock | 139 +- Cargo.toml | 6 +- contracts/dao-dao-core/Cargo.toml | 2 +- .../src/testing/mod.rs | 4 +- .../cw-vesting/src/suite_tests/suite.rs | 2 +- contracts/external/cw721-roles/src/tests.rs | 4 +- .../dao-voting-cw721-roles/src/error.rs | 2 +- .../dao-voting-cw721-staked/src/error.rs | 2 +- .../src/testing/mod.rs | 4 +- .../src/testing/tests.rs | 42 +- .../voting/dao-voting-onft-staked/Cargo.toml | 54 + .../voting/dao-voting-onft-staked/README.md | 45 + .../dao-voting-onft-staked/examples/schema.rs | 10 + .../schema/dao-voting-onft-staked.json | 1034 ++++++++++++++ .../dao-voting-onft-staked/src/contract.rs | 703 ++++++++++ .../dao-voting-onft-staked/src/error.rs | 48 + .../voting/dao-voting-onft-staked/src/lib.rs | 12 + .../voting/dao-voting-onft-staked/src/msg.rs | 119 ++ .../dao-voting-onft-staked/src/omniflix.rs | 32 + .../dao-voting-onft-staked/src/state.rs | 118 ++ .../dao-voting-onft-staked/src/testing/app.rs | 61 + .../src/testing/execute.rs | 236 ++++ .../src/testing/hooks.rs | 110 ++ .../dao-voting-onft-staked/src/testing/mod.rs | 73 + .../src/testing/omniflix_stargate.rs | 144 ++ .../src/testing/queries.rs | 116 ++ .../src/testing/tests.rs | 1188 +++++++++++++++++ .../voting/dao-voting-token-staked/Cargo.toml | 2 +- packages/dao-testing/Cargo.toml | 1 + packages/dao-testing/src/contracts.rs | 11 +- 31 files changed, 4274 insertions(+), 91 deletions(-) create mode 100644 contracts/voting/dao-voting-onft-staked/Cargo.toml create mode 100644 contracts/voting/dao-voting-onft-staked/README.md create mode 100644 contracts/voting/dao-voting-onft-staked/examples/schema.rs create mode 100644 contracts/voting/dao-voting-onft-staked/schema/dao-voting-onft-staked.json create mode 100644 contracts/voting/dao-voting-onft-staked/src/contract.rs create mode 100644 contracts/voting/dao-voting-onft-staked/src/error.rs create mode 100644 contracts/voting/dao-voting-onft-staked/src/lib.rs create mode 100644 contracts/voting/dao-voting-onft-staked/src/msg.rs create mode 100644 contracts/voting/dao-voting-onft-staked/src/omniflix.rs create mode 100644 contracts/voting/dao-voting-onft-staked/src/state.rs create mode 100644 contracts/voting/dao-voting-onft-staked/src/testing/app.rs create mode 100644 contracts/voting/dao-voting-onft-staked/src/testing/execute.rs create mode 100644 contracts/voting/dao-voting-onft-staked/src/testing/hooks.rs create mode 100644 contracts/voting/dao-voting-onft-staked/src/testing/mod.rs create mode 100644 contracts/voting/dao-voting-onft-staked/src/testing/omniflix_stargate.rs create mode 100644 contracts/voting/dao-voting-onft-staked/src/testing/queries.rs create mode 100644 contracts/voting/dao-voting-onft-staked/src/testing/tests.rs diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 47afc4655..854840036 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -9,27 +9,26 @@ env: jobs: build: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true - components: llvm-tools-preview - - name: cargo install cargo-llvm-cov - run: cargo install cargo-llvm-cov - - name: cargo llvm-cov - run: cargo llvm-cov --workspace --lcov --output-path lcov.info - - name: Codecov - # You may pin to the exact commit or the version. - # uses: codecov/codecov-action@81cd2dc8148241f03f5839d295e000b8f761e378 - uses: codecov/codecov-action@v3 - with: - # Repository upload token - get it from codecov.io. Required only for private repositories - # token: # optional - # Specify whether the Codecov output should be verbose - verbose: true - fail_ci_if_error: true + - uses: actions/checkout@v3 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + components: llvm-tools-preview + - name: cargo install cargo-llvm-cov + run: cargo install cargo-llvm-cov + - name: cargo llvm-cov + run: cargo llvm-cov --workspace --lcov --output-path lcov.info + - name: Codecov + # You may pin to the exact commit or the version. + # uses: codecov/codecov-action@81cd2dc8148241f03f5839d295e000b8f761e378 + uses: codecov/codecov-action@v3 + with: + # Repository upload token - get it from codecov.io. + token: ${{ secrets.CODECOV_TOKEN }} + # Specify whether the Codecov output should be verbose + verbose: true + fail_ci_if_error: true diff --git a/Cargo.lock b/Cargo.lock index 6f6eeed06..d3edb699c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,9 +39,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.75" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "assert_matches" @@ -68,7 +68,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.66", ] [[package]] @@ -79,7 +79,7 @@ checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.66", ] [[package]] @@ -203,7 +203,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.39", + "syn 2.0.66", "which", ] @@ -831,16 +831,17 @@ dependencies = [ [[package]] name = "cw-multi-test" -version = "0.18.0" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579e2c2f2c0877b839c5cad85e67811074e854a50c1ff3085eb8290b1c27809c" +checksum = "cc392a5cb7e778e3f90adbf7faa43c4db7f35b6623224b08886d796718edb875" dependencies = [ "anyhow", + "bech32", "cosmwasm-std", "cw-storage-plus 1.2.0", "cw-utils 1.0.3", "derivative", - "itertools 0.11.0", + "itertools 0.12.1", "prost 0.12.3", "schemars", "serde", @@ -2016,6 +2017,7 @@ dependencies = [ "dao-voting-cw4", "dao-voting-cw721-roles", "dao-voting-cw721-staked", + "dao-voting-onft-staked", "dao-voting-token-staked", "osmosis-std", "osmosis-test-tube", @@ -2159,6 +2161,35 @@ dependencies = [ "thiserror", ] +[[package]] +name = "dao-voting-onft-staked" +version = "2.5.0" +dependencies = [ + "anyhow", + "cosmwasm-schema", + "cosmwasm-std", + "cw-controllers 1.1.2", + "cw-hooks", + "cw-multi-test", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw2 1.1.2", + "cw721-controllers", + "dao-dao-macros", + "dao-hooks", + "dao-interface", + "dao-proposal-hook-counter", + "dao-proposal-single", + "dao-test-custom-factory", + "dao-testing", + "dao-voting 2.5.0", + "omniflix-std", + "osmosis-test-tube", + "prost 0.12.3", + "serde", + "thiserror", +] + [[package]] name = "dao-voting-token-staked" version = "2.5.0" @@ -2514,7 +2545,7 @@ checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.66", ] [[package]] @@ -2918,6 +2949,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.9" @@ -3128,6 +3168,35 @@ dependencies = [ "memchr", ] +[[package]] +name = "omniflix-std" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a662bd7782ef6ad1af3747a2b73d37f8e6a230bb7b1624d96c05b3567501600" +dependencies = [ + "chrono", + "cosmwasm-std", + "omniflix-std-derive", + "prost 0.12.3", + "prost-types 0.12.3", + "schemars", + "serde", + "serde-cw-value", +] + +[[package]] +name = "omniflix-std-derive" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bbd85582e3ef1a23fa7b12e0415ea604260c114e72faf40d829c2c40f1c745e" +dependencies = [ + "itertools 0.10.5", + "proc-macro2", + "prost-types 0.11.9", + "quote", + "syn 1.0.109", +] + [[package]] name = "once_cell" version = "1.18.0" @@ -3294,7 +3363,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.66", ] [[package]] @@ -3325,7 +3394,7 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.66", ] [[package]] @@ -3373,14 +3442,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae005bd773ab59b4725093fd7df83fd7892f7d8eafb48dbd7de6e024e4215f9d" dependencies = [ "proc-macro2", - "syn 2.0.39", + "syn 2.0.66", ] [[package]] name = "proc-macro2" -version = "1.0.70" +version = "1.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" dependencies = [ "unicode-ident", ] @@ -3440,7 +3509,7 @@ dependencies = [ "itertools 0.11.0", "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.66", ] [[package]] @@ -3463,9 +3532,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.33" +version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ "proc-macro2", ] @@ -3785,9 +3854,9 @@ checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" [[package]] name = "serde" -version = "1.0.193" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" dependencies = [ "serde_derive", ] @@ -3821,13 +3890,13 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.193" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.66", ] [[package]] @@ -3860,7 +3929,7 @@ checksum = "3081f5ffbb02284dda55132aa26daecedd7372a42417bbbab6f14ab7d6bb9145" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.66", ] [[package]] @@ -4094,9 +4163,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.39" +version = "2.0.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" +checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" dependencies = [ "proc-macro2", "quote", @@ -4368,22 +4437,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.50" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" +checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.50" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" +checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.66", ] [[package]] @@ -4453,7 +4522,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.66", ] [[package]] @@ -4612,7 +4681,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.66", ] [[package]] @@ -4766,7 +4835,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.66", "wasm-bindgen-shared", ] @@ -4788,7 +4857,7 @@ checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.66", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -5041,5 +5110,5 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.39", + "syn 2.0.66", ] diff --git a/Cargo.toml b/Cargo.toml index be1a87726..52032a9ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,9 +40,9 @@ cosm-orc = { version = "4.0" } cosm-tome = "0.2" cosmos-sdk-proto = "0.19" cosmwasm-schema = { version = "1.5.4" } -cosmwasm-std = { version = "1.5.4", features = ["ibc3"] } +cosmwasm-std = { version = "1.5.4", features = ["ibc3", "cosmwasm_1_1"] } cw-controllers = "1.1" -cw-multi-test = "0.18" +cw-multi-test = { version = "0.20.1", features = ["cosmwasm_1_1"] } cw-storage-plus = { version = "1.1" } cw-utils = "1.0" cw2 = "1.1" @@ -55,6 +55,7 @@ cw721 = "0.18" cw721-base = "0.18" env_logger = "0.10" once_cell = "1.18" +omniflix-std = "0.1.8" osmosis-std = "0.20.1" osmosis-std-derive = "0.20.1" osmosis-test-tube = "20.1.1" @@ -119,6 +120,7 @@ dao-voting-cw20-staked = { path = "./contracts/voting/dao-voting-cw20-staked", v dao-voting-cw4 = { path = "./contracts/voting/dao-voting-cw4", version = "2.5.0" } dao-voting-cw721-roles = { path = "./contracts/voting/dao-voting-cw721-roles", version = "2.5.0" } dao-voting-cw721-staked = { path = "./contracts/voting/dao-voting-cw721-staked", version = "2.5.0" } +dao-voting-onft-staked = { path = "./contracts/voting/dao-voting-onft-staked", version = "2.5.0" } dao-voting-token-staked = { path = "./contracts/voting/dao-voting-token-staked", version = "2.5.0" } # v1 dependencies. used for state migrations. diff --git a/contracts/dao-dao-core/Cargo.toml b/contracts/dao-dao-core/Cargo.toml index ac8e2060d..a9ac8dae6 100644 --- a/contracts/dao-dao-core/Cargo.toml +++ b/contracts/dao-dao-core/Cargo.toml @@ -31,7 +31,7 @@ cw-paginate-storage = { workspace = true } cw-core-v1 = { workspace = true, features = ["library"] } [dev-dependencies] -cw-multi-test = { workspace = true, features = ["stargate"] } +cw-multi-test = { workspace = true } cw20-base = { workspace = true } cw721-base = { workspace = true } dao-proposal-sudo = { workspace = true } diff --git a/contracts/distribution/dao-rewards-distributor/src/testing/mod.rs b/contracts/distribution/dao-rewards-distributor/src/testing/mod.rs index f0b1d66ab..352ed7cec 100644 --- a/contracts/distribution/dao-rewards-distributor/src/testing/mod.rs +++ b/contracts/distribution/dao-rewards-distributor/src/testing/mod.rs @@ -219,7 +219,7 @@ mod cw721_setup { use cosmwasm_std::{to_json_binary, Addr, Binary, Empty}; use cw_multi_test::{App, Executor}; - use dao_testing::contracts::{cw721_base_contract, voting_cw721_staked_contract}; + use dao_testing::contracts::{cw721_base_contract, cw721_staked_voting_contract}; use dao_voting_cw721_staked::state::Config; use super::OWNER; @@ -255,7 +255,7 @@ mod cw721_setup { pub fn setup_cw721_test(app: &mut App, initial_nfts: Vec) -> (Addr, Addr) { let cw721_code_id = app.store_code(cw721_base_contract()); - let vp_code_id = app.store_code(voting_cw721_staked_contract()); + let vp_code_id = app.store_code(cw721_staked_voting_contract()); let msg = dao_voting_cw721_staked::msg::InstantiateMsg { nft_contract: dao_voting_cw721_staked::msg::NftContract::New { diff --git a/contracts/external/cw-vesting/src/suite_tests/suite.rs b/contracts/external/cw-vesting/src/suite_tests/suite.rs index 401aeb8f3..7558b9ad9 100644 --- a/contracts/external/cw-vesting/src/suite_tests/suite.rs +++ b/contracts/external/cw-vesting/src/suite_tests/suite.rs @@ -170,7 +170,7 @@ impl Suite { } pub fn process_unbonds(&mut self) { - self.app.sudo(StakingSudo::ProcessQueue {}.into()).unwrap(); + self.app.update_block(|_| {}); } } diff --git a/contracts/external/cw721-roles/src/tests.rs b/contracts/external/cw721-roles/src/tests.rs index 005c2e61f..84d1cc6d8 100644 --- a/contracts/external/cw721-roles/src/tests.rs +++ b/contracts/external/cw721-roles/src/tests.rs @@ -3,7 +3,7 @@ use cw4::{HooksResponse, Member, MemberListResponse, MemberResponse, TotalWeight use cw721::{NftInfoResponse, OwnerOfResponse}; use cw_multi_test::{App, Executor}; use dao_cw721_extensions::roles::{ExecuteExt, MetadataExt, QueryExt}; -use dao_testing::contracts::{cw721_roles_contract, voting_cw721_staked_contract}; +use dao_testing::contracts::{cw721_roles_contract, cw721_staked_voting_contract}; use dao_voting_cw721_staked::msg::{InstantiateMsg as Cw721StakedInstantiateMsg, NftContract}; use crate::error::RolesContractError; @@ -254,7 +254,7 @@ fn test_send_permissions() { .unwrap(); // Instantiate an NFT staking voting contract for testing SendNft - let dao_voting_cw721_staked_id = app.store_code(voting_cw721_staked_contract()); + let dao_voting_cw721_staked_id = app.store_code(cw721_staked_voting_contract()); let cw721_staked_addr = app .instantiate_contract( dao_voting_cw721_staked_id, diff --git a/contracts/voting/dao-voting-cw721-roles/src/error.rs b/contracts/voting/dao-voting-cw721-roles/src/error.rs index 2fa498222..62c33b0c6 100644 --- a/contracts/voting/dao-voting-cw721-roles/src/error.rs +++ b/contracts/voting/dao-voting-cw721-roles/src/error.rs @@ -15,7 +15,7 @@ pub enum ContractError { #[error("New cw721-roles contract must be instantiated with at least one NFT")] NoInitialNfts {}, - #[error("Only the owner of this contract my execute this message")] + #[error("Only the owner of this contract may execute this message")] NotOwner {}, #[error("Got a submessage reply with unknown id: {id}")] diff --git a/contracts/voting/dao-voting-cw721-staked/src/error.rs b/contracts/voting/dao-voting-cw721-staked/src/error.rs index 287c7a509..df9526bd2 100644 --- a/contracts/voting/dao-voting-cw721-staked/src/error.rs +++ b/contracts/voting/dao-voting-cw721-staked/src/error.rs @@ -38,7 +38,7 @@ pub enum ContractError { #[error("Nothing to claim")] NothingToClaim {}, - #[error("Only the owner of this contract my execute this message")] + #[error("Only the owner of this contract may execute this message")] NotOwner {}, #[error("Can not unstake that which you have not staked (unstaking {token_id})")] diff --git a/contracts/voting/dao-voting-cw721-staked/src/testing/mod.rs b/contracts/voting/dao-voting-cw721-staked/src/testing/mod.rs index de0824f52..c454212c4 100644 --- a/contracts/voting/dao-voting-cw721-staked/src/testing/mod.rs +++ b/contracts/voting/dao-voting-cw721-staked/src/testing/mod.rs @@ -18,7 +18,7 @@ mod test_tube_env; use cosmwasm_std::Addr; use cw_multi_test::{App, Executor}; use cw_utils::Duration; -use dao_testing::contracts::voting_cw721_staked_contract; +use dao_testing::contracts::cw721_staked_voting_contract; use crate::msg::{InstantiateMsg, NftContract}; @@ -35,7 +35,7 @@ pub(crate) struct CommonTest { pub(crate) fn setup_test(unstaking_duration: Option) -> CommonTest { let mut app = App::default(); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let nft = instantiate_cw721_base(&mut app, CREATOR_ADDR, CREATOR_ADDR); let module = app diff --git a/contracts/voting/dao-voting-cw721-staked/src/testing/tests.rs b/contracts/voting/dao-voting-cw721-staked/src/testing/tests.rs index cdc057bb7..7126bd904 100644 --- a/contracts/voting/dao-voting-cw721-staked/src/testing/tests.rs +++ b/contracts/voting/dao-voting-cw721-staked/src/testing/tests.rs @@ -6,7 +6,7 @@ use cw_multi_test::{next_block, App, BankSudo, Executor, SudoMsg}; use cw_utils::Duration; use dao_interface::voting::IsActiveResponse; use dao_testing::contracts::{ - cw721_base_contract, dao_test_custom_factory, voting_cw721_staked_contract, + cw721_base_contract, cw721_staked_voting_contract, dao_test_custom_factory, }; use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; @@ -34,7 +34,7 @@ use super::{ #[test] fn test_instantiate_with_new_cw721_collection() -> anyhow::Result<()> { let mut app = App::default(); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let cw721_id = app.store_code(cw721_base_contract()); let module_addr = app @@ -421,7 +421,7 @@ fn test_add_remove_hooks() -> anyhow::Result<()> { #[test] fn test_instantiate_with_invalid_duration_fails() { let mut app = App::default(); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let cw721_id = app.store_code(cw721_base_contract()); let err = app @@ -462,7 +462,7 @@ fn test_instantiate_with_invalid_duration_fails() { fn test_instantiate_zero_active_threshold_count() { let mut app = App::default(); let cw721_id = app.store_code(cw721_base_contract()); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); app.instantiate_contract( module_id, @@ -502,7 +502,7 @@ fn test_instantiate_zero_active_threshold_count() { fn test_instantiate_invalid_active_threshold_count_new_nft() { let mut app = App::default(); let cw721_id = app.store_code(cw721_base_contract()); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); app.instantiate_contract( module_id, @@ -541,7 +541,7 @@ fn test_instantiate_invalid_active_threshold_count_new_nft() { #[should_panic(expected = "Absolute count threshold cannot be greater than the total token supply")] fn test_instantiate_invalid_active_threshold_count_existing_nft() { let mut app = App::default(); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let cw721_addr = instantiate_cw721_base(&mut app, CREATOR_ADDR, CREATOR_ADDR); app.instantiate_contract( @@ -567,7 +567,7 @@ fn test_instantiate_invalid_active_threshold_count_existing_nft() { fn test_active_threshold_absolute_count() { let mut app = App::default(); let cw721_id = app.store_code(cw721_base_contract()); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let voting_addr = app .instantiate_contract( @@ -647,7 +647,7 @@ fn test_active_threshold_absolute_count() { fn test_active_threshold_percent() { let mut app = App::default(); let cw721_id = app.store_code(cw721_base_contract()); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let voting_addr = app .instantiate_contract( @@ -708,7 +708,7 @@ fn test_active_threshold_percent() { fn test_active_threshold_percent_rounds_up() { let mut app = App::default(); let cw721_id = app.store_code(cw721_base_contract()); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let voting_addr = app .instantiate_contract( @@ -810,7 +810,7 @@ fn test_active_threshold_percent_rounds_up() { fn test_update_active_threshold() { let mut app = App::default(); let cw721_id = app.store_code(cw721_base_contract()); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let voting_addr = app .instantiate_contract( @@ -887,7 +887,7 @@ fn test_update_active_threshold() { fn test_active_threshold_percentage_gt_100() { let mut app = App::default(); let cw721_id = app.store_code(cw721_base_contract()); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); app.instantiate_contract( module_id, @@ -929,7 +929,7 @@ fn test_active_threshold_percentage_gt_100() { fn test_active_threshold_percentage_lte_0() { let mut app = App::default(); let cw721_id = app.store_code(cw721_base_contract()); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); app.instantiate_contract( module_id, @@ -967,7 +967,7 @@ fn test_active_threshold_percentage_lte_0() { #[test] fn test_invalid_instantiate_msg() { let mut app = App::default(); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let cw721_id = app.store_code(cw721_base_contract()); let err = app @@ -1006,7 +1006,7 @@ fn test_invalid_instantiate_msg() { #[test] fn test_invalid_initial_nft_msg() { let mut app = App::default(); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let cw721_id = app.store_code(cw721_base_contract()); let err = app @@ -1045,7 +1045,7 @@ fn test_invalid_initial_nft_msg() { #[test] fn test_invalid_initial_nft_msg_wrong_absolute_count() { let mut app = App::default(); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let cw721_id = app.store_code(cw721_base_contract()); let err = app @@ -1096,7 +1096,7 @@ fn test_invalid_initial_nft_msg_wrong_absolute_count() { fn test_no_initial_nfts_fails() { let mut app = App::default(); let cw721_id = app.store_code(cw721_base_contract()); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let err = app .instantiate_contract( @@ -1133,7 +1133,7 @@ fn test_no_initial_nfts_fails() { #[test] fn test_factory() { let mut app = App::default(); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let cw721_id = app.store_code(cw721_base_contract()); let factory_id = app.store_code(dao_test_custom_factory()); @@ -1186,7 +1186,7 @@ fn test_factory() { #[test] fn test_factory_with_funds_pass_through() { let mut app = App::default(); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let cw721_id = app.store_code(cw721_base_contract()); let factory_id = app.store_code(dao_test_custom_factory()); @@ -1307,7 +1307,7 @@ fn test_factory_with_funds_pass_through() { #[should_panic(expected = "Factory message must serialize to WasmMsg::Execute")] fn test_unsupported_factory_msg() { let mut app = App::default(); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let cw721_id = app.store_code(cw721_base_contract()); // Instantiate using factory succeeds @@ -1352,7 +1352,7 @@ fn test_unsupported_factory_msg() { )] fn test_factory_wrong_callback() { let mut app = App::default(); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let _cw721_id = app.store_code(cw721_base_contract()); let factory_id = app.store_code(dao_test_custom_factory()); @@ -1400,7 +1400,7 @@ fn test_factory_wrong_callback() { #[should_panic(expected = "Invalid reply from sub-message: Missing reply data")] fn test_factory_no_callback() { let mut app = App::default(); - let module_id = app.store_code(voting_cw721_staked_contract()); + let module_id = app.store_code(cw721_staked_voting_contract()); let _cw721_id = app.store_code(cw721_base_contract()); let factory_id = app.store_code(dao_test_custom_factory()); diff --git a/contracts/voting/dao-voting-onft-staked/Cargo.toml b/contracts/voting/dao-voting-onft-staked/Cargo.toml new file mode 100644 index 000000000..bd6481805 --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "dao-voting-onft-staked" +authors = [ + "CypherApe cypherape@protonmail.com", + "Jake Hartnell", + "ekez", + "noah ", +] +description = "A DAO DAO voting module based on staked x/onft tokens." +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 = [] +# use test tube feature to enable test-tube integration tests, for example +# cargo test --features "test-tube" +test-tube = [] +# when writing tests you may wish to enable test-tube as a default feature +# default = ["test-tube"] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw-controllers = { workspace = true } +cw-hooks = { workspace = true } +cw721-controllers = { workspace = true } +cw-utils = { workspace = true } +cw2 = { workspace = true } +dao-dao-macros = { workspace = true } +dao-hooks = { workspace = true } +dao-interface = { workspace = true } +dao-voting = { workspace = true } +omniflix-std = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +cw-multi-test = { workspace = true } +dao-proposal-single = { workspace = true } +dao-proposal-hook-counter = { workspace = true } +dao-test-custom-factory = { workspace = true } +dao-testing = { workspace = true, features = ["test-tube"] } +osmosis-test-tube = { workspace = true } +prost = { workspace = true } diff --git a/contracts/voting/dao-voting-onft-staked/README.md b/contracts/voting/dao-voting-onft-staked/README.md new file mode 100644 index 000000000..ac844ba94 --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/README.md @@ -0,0 +1,45 @@ +# `dao-voting-onft-staked` + +This is a basic implementation of an NFT staking contract that supports +OmniFlix's NFT standard: +[x/onft](https://github.com/OmniFlix/omniflixhub/tree/main/x/onft). + +Staked tokens can be unbonded with a configurable unbonding period. Staked balances can be queried at any arbitrary height by external contracts. This contract implements the interface needed to be a DAO DAO [voting module](https://github.com/DA0-DA0/dao-contracts/wiki/DAO-DAO-Contracts-Design#the-voting-module). + +### Stake process + +Unlike the base cw721 smart contract, the x/onft SDK module doesn't support +executing a smart contract on NFT transfer, so the stake process is broken up +into three steps: + +1. The sender calls `PrepareStake` to inform this staking contract of the NFTs + that are about to be staked. This will succeed only if the sender currently + owns the NFT(s). +2. The sender then transfers the NFT(s) to the staking contract. +3. The sender calls `ConfirmStake` on this staking contract which confirms the + NFTs were transferred to it and registers the stake. + +In case this process is interrupted, or executed incorrectly (e.g. the sender +accidentally transfers an NFT to the staking contract without first preparing +it), there is also a `CancelStake` action to help recover NFTs. If called by: + +- the original stake preparer, the preparation will be canceled, and the NFT(s) + will be sent back if the staking contract owns them. +- the current NFT(s) owner, the preparation will be canceled, if any. +- the DAO, the preparation will be canceled (if any exists), and the NFT(s) will + be sent to the specified recipient (if the staking contract owns them). if no + recipient is specified but the NFT was prepared, it will be sent back to the + preparer. + +The recipient field only applies when the sender is the DAO. In the other cases, +the NFT(s) will always be sent back to the sender. Note: if the NFTs were sent +to the staking contract, but no stake was prepared, only the DAO will be able to +correct this and send them somewhere. + +The `PrepareStake` step overrides any previous `PrepareStake` calls as long as +the new sender owns the NFT(s) and the first stake was never confirmed (which +should be impossible if someone else now owns the NFT(s)). Thus there is no +combination of messages or steps where someone can stake nor prevent stake when +it would otherwise be valid. A stake is only ever confirmed if it was prepared +and transferred by the same address confirming, and the DAO can always recover +an NFT that accidentally skipped the preparation step. diff --git a/contracts/voting/dao-voting-onft-staked/examples/schema.rs b/contracts/voting/dao-voting-onft-staked/examples/schema.rs new file mode 100644 index 000000000..45e321363 --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/examples/schema.rs @@ -0,0 +1,10 @@ +use cosmwasm_schema::write_api; +use dao_voting_onft_staked::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + } +} diff --git a/contracts/voting/dao-voting-onft-staked/schema/dao-voting-onft-staked.json b/contracts/voting/dao-voting-onft-staked/schema/dao-voting-onft-staked.json new file mode 100644 index 000000000..07d68128f --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/schema/dao-voting-onft-staked.json @@ -0,0 +1,1034 @@ +{ + "contract_name": "dao-voting-onft-staked", + "contract_version": "2.5.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "onft_collection" + ], + "properties": { + "active_threshold": { + "description": "The number or percentage of tokens that must be staked for the DAO to be active", + "anyOf": [ + { + "$ref": "#/definitions/ActiveThreshold" + }, + { + "type": "null" + } + ] + }, + "onft_collection": { + "description": "ONFT collection that will be staked.", + "allOf": [ + { + "$ref": "#/definitions/OnftCollection" + } + ] + }, + "unstaking_duration": { + "description": "Amount of time between unstaking and tokens being available. To unstake with no delay, leave as `None`.", + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "ActiveThreshold": { + "description": "The threshold of tokens that must be staked in order for this voting module to be active. If this is not reached, this module will response to `is_active` queries with false and proposal modules which respect active thresholds will not allow the creation of proposals.", + "oneOf": [ + { + "description": "The absolute number of tokens that must be staked for the module to be active.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The percentage of tokens that must be staked for the module to be active. Computed as `staked / total_supply`.", + "type": "object", + "required": [ + "percentage" + ], + "properties": { + "percentage": { + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + }, + "Duration": { + "description": "Duration is a delta of time. You can add it to a BlockInfo or Expiration to move that further in the future. Note that an height-based Duration and a time-based Expiration cannot be combined", + "oneOf": [ + { + "type": "object", + "required": [ + "height" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "Time in seconds", + "type": "object", + "required": [ + "time" + ], + "properties": { + "time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + ] + }, + "OnftCollection": { + "oneOf": [ + { + "description": "Uses an existing x/onft denom/collection.", + "type": "object", + "required": [ + "existing" + ], + "properties": { + "existing": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "description": "ID of an already created x/onft denom/collection.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "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": "Step 1/3 of the NFT staking process. x/onft doesn't support executing a smart contract on NFT transfer like cw721s do, so the stake process is broken up: 1. The sender calls `PrepareStake` to inform this staking contract of the NFTs that are about to be staked. This will succeed only if the sender currently owns the NFT(s). 2. The sender then transfers the NFT(s) to the staking contract. 3. The sender calls `ConfirmStake` on this staking contract which confirms the NFTs were transferred to it and registers the stake.\n\nPrepareStake overrides any previous PrepareStake calls, as long as the sender owns the NFT(s).", + "type": "object", + "required": [ + "prepare_stake" + ], + "properties": { + "prepare_stake": { + "type": "object", + "required": [ + "token_ids" + ], + "properties": { + "token_ids": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Step 3/3 of the NFT staking process. x/onft doesn't support executing a smart contract on NFT transfer like cw721s do, so the stake process is broken up: 1. The sender calls `PrepareStake` to inform this staking contract of the NFTs that are about to be staked. This will succeed only if the sender currently owns the NFT(s). 2. The sender then transfers the NFT(s) to the staking contract. 3. The sender calls `ConfirmStake` on this staking contract which confirms the NFTs were transferred to it and registers the stake.", + "type": "object", + "required": [ + "confirm_stake" + ], + "properties": { + "confirm_stake": { + "type": "object", + "required": [ + "token_ids" + ], + "properties": { + "token_ids": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "CancelStake serves as an undo function in case an NFT or stake gets into a bad state, either because the stake process was never completed, or because someone sent an NFT to the staking contract without preparing the stake first.\n\nIf called by: - the original stake preparer, the preparation will be canceled, and the NFT(s) will be sent back if the staking contract owns them. - the current NFT(s) owner, the preparation will be canceled, if any. - the DAO, the preparation will be canceled (if any exists), and the NFT(s) will be sent to the specified recipient (if the staking contract owns them). if no recipient is specified but the NFT was prepared, it will be sent back to the preparer.\n\nThe recipient field only applies when the sender is the DAO. In the other cases, the NFT(s) will always be sent back to the sender. Note: if the NFTs were sent to the staking contract, but no stake was prepared, only the DAO will be able to correct this and send them somewhere.", + "type": "object", + "required": [ + "cancel_stake" + ], + "properties": { + "cancel_stake": { + "type": "object", + "required": [ + "token_ids" + ], + "properties": { + "recipient": { + "type": [ + "string", + "null" + ] + }, + "token_ids": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Unstakes the specified token_ids on behalf of the sender. token_ids must have unique values and have non-zero length.", + "type": "object", + "required": [ + "unstake" + ], + "properties": { + "unstake": { + "type": "object", + "required": [ + "token_ids" + ], + "properties": { + "token_ids": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Claim NFTs that have been unstaked for the specified duration.", + "type": "object", + "required": [ + "claim_nfts" + ], + "properties": { + "claim_nfts": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Updates the contract configuration, namely unstaking duration. Only callable by the DAO that initialized this voting contract.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "properties": { + "duration": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Adds a hook which is called on staking / unstaking events. Only callable by the DAO that initialized this voting contract.", + "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 staking / unstaking events. Only callable by the DAO that initialized this voting contract.", + "type": "object", + "required": [ + "remove_hook" + ], + "properties": { + "remove_hook": { + "type": "object", + "required": [ + "addr" + ], + "properties": { + "addr": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Sets the active threshold to a new value. Only callable by the DAO that initialized this voting contract.", + "type": "object", + "required": [ + "update_active_threshold" + ], + "properties": { + "update_active_threshold": { + "type": "object", + "properties": { + "new_threshold": { + "anyOf": [ + { + "$ref": "#/definitions/ActiveThreshold" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "definitions": { + "ActiveThreshold": { + "description": "The threshold of tokens that must be staked in order for this voting module to be active. If this is not reached, this module will response to `is_active` queries with false and proposal modules which respect active thresholds will not allow the creation of proposals.", + "oneOf": [ + { + "description": "The absolute number of tokens that must be staked for the module to be active.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The percentage of tokens that must be staked for the module to be active. Computed as `staked / total_supply`.", + "type": "object", + "required": [ + "percentage" + ], + "properties": { + "percentage": { + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + }, + "Duration": { + "description": "Duration is a delta of time. You can add it to a BlockInfo or Expiration to move that further in the future. Note that an height-based Duration and a time-based Expiration cannot be combined", + "oneOf": [ + { + "type": "object", + "required": [ + "height" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "Time in seconds", + "type": "object", + "required": [ + "time" + ], + "properties": { + "time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "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" + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "nft_claims" + ], + "properties": { + "nft_claims": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "staked_nfts" + ], + "properties": { + "staked_nfts": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "active_threshold" + ], + "properties": { + "active_threshold": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "is_active" + ], + "properties": { + "is_active": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the voting power for an address at a given height.", + "type": "object", + "required": [ + "voting_power_at_height" + ], + "properties": { + "voting_power_at_height": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + }, + "height": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the total voting power at a given block heigh.", + "type": "object", + "required": [ + "total_power_at_height" + ], + "properties": { + "total_power_at_height": { + "type": "object", + "properties": { + "height": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns the address of the DAO this module belongs to.", + "type": "object", + "required": [ + "dao" + ], + "properties": { + "dao": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Returns contract version info.", + "type": "object", + "required": [ + "info" + ], + "properties": { + "info": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": null, + "sudo": null, + "responses": { + "active_threshold": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ActiveThresholdResponse", + "type": "object", + "properties": { + "active_threshold": { + "anyOf": [ + { + "$ref": "#/definitions/ActiveThreshold" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "ActiveThreshold": { + "description": "The threshold of tokens that must be staked in order for this voting module to be active. If this is not reached, this module will response to `is_active` queries with false and proposal modules which respect active thresholds will not allow the creation of proposals.", + "oneOf": [ + { + "description": "The absolute number of tokens that must be staked for the module to be active.", + "type": "object", + "required": [ + "absolute_count" + ], + "properties": { + "absolute_count": { + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "$ref": "#/definitions/Uint128" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "The percentage of tokens that must be staked for the module to be active. Computed as `staked / total_supply`.", + "type": "object", + "required": [ + "percentage" + ], + "properties": { + "percentage": { + "type": "object", + "required": [ + "percent" + ], + "properties": { + "percent": { + "$ref": "#/definitions/Decimal" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "Decimal": { + "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)", + "type": "string" + }, + "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" + } + } + }, + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "type": "object", + "required": [ + "onft_collection_id" + ], + "properties": { + "onft_collection_id": { + "type": "string" + }, + "unstaking_duration": { + "anyOf": [ + { + "$ref": "#/definitions/Duration" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "definitions": { + "Duration": { + "description": "Duration is a delta of time. You can add it to a BlockInfo or Expiration to move that further in the future. Note that an height-based Duration and a time-based Expiration cannot be combined", + "oneOf": [ + { + "type": "object", + "required": [ + "height" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "Time in seconds", + "type": "object", + "required": [ + "time" + ], + "properties": { + "time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + } + ] + } + } + }, + "dao": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "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" + }, + "hooks": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HooksResponse", + "type": "object", + "required": [ + "hooks" + ], + "properties": { + "hooks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "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 + } + } + }, + "is_active": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Boolean", + "type": "boolean" + }, + "nft_claims": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NftClaimsResponse", + "type": "object", + "required": [ + "nft_claims" + ], + "properties": { + "nft_claims": { + "type": "array", + "items": { + "$ref": "#/definitions/NftClaim" + } + } + }, + "additionalProperties": false, + "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 + } + ] + }, + "NftClaim": { + "type": "object", + "required": [ + "release_at", + "token_id" + ], + "properties": { + "release_at": { + "$ref": "#/definitions/Expiration" + }, + "token_id": { + "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" + } + ] + }, + "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" + } + } + }, + "staked_nfts": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Array_of_String", + "type": "array", + "items": { + "type": "string" + } + }, + "total_power_at_height": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TotalPowerAtHeightResponse", + "type": "object", + "required": [ + "height", + "power" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "power": { + "$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" + } + } + }, + "voting_power_at_height": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "VotingPowerAtHeightResponse", + "type": "object", + "required": [ + "height", + "power" + ], + "properties": { + "height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "power": { + "$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/voting/dao-voting-onft-staked/src/contract.rs b/contracts/voting/dao-voting-onft-staked/src/contract.rs new file mode 100644 index 000000000..9c105ddd7 --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/contract.rs @@ -0,0 +1,703 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_json_binary, Addr, Binary, CosmosMsg, Deps, DepsMut, Empty, Env, MessageInfo, Response, + StdResult, SubMsg, Uint128, Uint256, +}; +use cw2::{get_contract_version, set_contract_version, ContractVersion}; +use cw_storage_plus::Bound; +use cw_utils::Duration; +use dao_hooks::nft_stake::{stake_nft_hook_msgs, unstake_nft_hook_msgs}; +use dao_interface::voting::IsActiveResponse; +use dao_voting::duration::validate_duration; +use dao_voting::threshold::{ + assert_valid_absolute_count_threshold, assert_valid_percentage_threshold, ActiveThreshold, + ActiveThresholdResponse, +}; + +use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, OnftCollection, QueryMsg}; +use crate::omniflix::{get_onft_transfer_msg, query_onft_owner, query_onft_supply}; +use crate::state::{ + register_staked_nfts, register_unstaked_nfts, Config, ACTIVE_THRESHOLD, CONFIG, DAO, HOOKS, + MAX_CLAIMS, NFT_BALANCES, NFT_CLAIMS, PREPARED_ONFTS, STAKED_NFTS_PER_OWNER, TOTAL_STAKED_NFTS, +}; +use crate::ContractError; + +pub(crate) const CONTRACT_NAME: &str = "crates.io:dao-voting-onft-staked"; +pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +// We multiply by this when calculating needed power for being active +// when using active threshold with percent +const PRECISION_FACTOR: u128 = 10u128.pow(9); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result, ContractError> { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + DAO.save(deps.storage, &info.sender)?; + + // Validate unstaking duration + validate_duration(msg.unstaking_duration)?; + + // Validate active threshold if configured + if let Some(active_threshold) = msg.active_threshold.as_ref() { + match active_threshold { + ActiveThreshold::Percentage { percent } => { + assert_valid_percentage_threshold(*percent)?; + } + ActiveThreshold::AbsoluteCount { count } => { + // Check absolute count is less than the supply of NFTs for + // existing NFT collection. + + let OnftCollection::Existing { ref id } = msg.onft_collection; + let nft_supply = query_onft_supply(deps.as_ref(), id)?; + + // Check the absolute count is less than the supply of NFTs and + // greater than zero. + assert_valid_absolute_count_threshold(*count, Uint128::new(nft_supply.into()))?; + } + } + ACTIVE_THRESHOLD.save(deps.storage, active_threshold)?; + } + + TOTAL_STAKED_NFTS.save(deps.storage, &Uint128::zero(), env.block.height)?; + + match msg.onft_collection { + OnftCollection::Existing { id } => { + let config = Config { + onft_collection_id: id.clone(), + unstaking_duration: msg.unstaking_duration, + }; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::default() + .add_attribute("method", "instantiate") + .add_attribute("onft_collection_id", id)) + } + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result, ContractError> { + match msg { + ExecuteMsg::PrepareStake { token_ids } => execute_prepare_stake(deps, info, token_ids), + ExecuteMsg::ConfirmStake { token_ids } => execute_confirm_stake(deps, env, info, token_ids), + ExecuteMsg::CancelStake { + token_ids, + recipient, + } => execute_cancel_stake(deps, env, info, token_ids, recipient), + ExecuteMsg::Unstake { token_ids } => execute_unstake(deps, env, info, token_ids), + ExecuteMsg::ClaimNfts {} => execute_claim_nfts(deps, env, info), + ExecuteMsg::UpdateConfig { duration } => execute_update_config(info, deps, duration), + ExecuteMsg::AddHook { addr } => execute_add_hook(deps, info, addr), + ExecuteMsg::RemoveHook { addr } => execute_remove_hook(deps, info, addr), + ExecuteMsg::UpdateActiveThreshold { new_threshold } => { + execute_update_active_threshold(deps, env, info, new_threshold) + } + } +} + +pub fn execute_prepare_stake( + deps: DepsMut, + info: MessageInfo, + token_ids: Vec, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // verify sender owns all the tokens + let owns_all = token_ids + .iter() + .map(|token_id| -> StdResult { + let owner = query_onft_owner(deps.as_ref(), &config.onft_collection_id, token_id)?; + + Ok(owner == info.sender) + }) + .collect::>>()? + .into_iter() + .all(|b| b); + + if !owns_all { + return Err(ContractError::OnlyOwnerCanPrepareStake {}); + } + + // save and override prepared ONFTS, readying them to be transferred and + // staked + for token_id in &token_ids { + PREPARED_ONFTS.save(deps.storage, token_id.to_string(), &info.sender)?; + } + + Ok(Response::default() + .add_attribute("action", "prepare_stake") + .add_attribute("preparer", info.sender.to_string()) + .add_attribute("token_ids", token_ids.join(","))) +} + +pub fn execute_confirm_stake( + deps: DepsMut, + env: Env, + info: MessageInfo, + token_ids: Vec, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + // verify sender prepared and transferred all the tokens + let sender_prepared_all = token_ids + .iter() + .map(|token_id| -> StdResult { + // check if sender prepared + let prepared = PREPARED_ONFTS + .may_load(deps.storage, token_id.to_string())? + .map_or(false, |preparer| preparer == info.sender); + + // check that NFT was transferred to this contract + let owner = query_onft_owner(deps.as_ref(), &config.onft_collection_id, token_id)?; + + Ok(prepared && owner == env.contract.address) + }) + .collect::>>()? + .into_iter() + .all(|b| b); + + if !sender_prepared_all { + return Err(ContractError::StakeMustBePrepared {}); + } + + register_staked_nfts(deps.storage, env.block.height, &info.sender, &token_ids)?; + + // remove preparations + for token_id in &token_ids { + PREPARED_ONFTS.remove(deps.storage, token_id.to_string()); + } + + let hook_msgs = token_ids + .iter() + .map(|token_id| { + stake_nft_hook_msgs(HOOKS, deps.storage, info.sender.clone(), token_id.clone()) + }) + .collect::>>>()? + .into_iter() + .flatten() + .collect::>(); + + Ok(Response::default() + .add_submessages(hook_msgs) + .add_attribute("action", "stake") + .add_attribute("from", info.sender) + .add_attribute("token_ids", token_ids.join(","))) +} + +/// CancelStake serves as an undo function in case an NFT or stake gets into a +/// bad state, either because the stake process was never completed, or because +/// someone sent an NFT to the staking contract without preparing the stake +/// first. +/// +/// If called by: +/// - the original stake preparer, the preparation will be canceled, and the +/// NFT(s) will be sent back if the staking contract owns them. +/// - the current NFT(s) owner, the preparation will be canceled, if any. +/// - the DAO, the preparation will be canceled (if any exists), and the NFT(s) +/// will be sent to the specified recipient (if the staking contract owns +/// them). if no recipient is specified but the NFT was prepared, it will be +/// sent back to the preparer. +/// +/// The recipient field only applies when the sender is the DAO. In the other +/// cases, the NFT(s) will always be sent back to the sender. Note: if the NFTs +/// were sent to the staking contract, but no stake was prepared, only the DAO +/// will be able to correct this and send them somewhere. +pub fn execute_cancel_stake( + deps: DepsMut, + env: Env, + info: MessageInfo, + token_ids: Vec, + recipient: Option, +) -> Result { + let dao = DAO.load(deps.storage)?; + let config = CONFIG.load(deps.storage)?; + + // get preparers and owners of NFTs + let token_ids_with_owners_and_preparers = token_ids + .iter() + .map(|token_id| { + let preparer = PREPARED_ONFTS.may_load(deps.storage, token_id.clone())?; + + let owner = query_onft_owner(deps.as_ref(), &config.onft_collection_id, token_id)?; + + Ok((token_id, owner, preparer)) + }) + .collect::)>>>()?; + + let mut transfer_msgs: Vec = vec![]; + + // If DAO, cancel preparations (if any) and send NFTs to the specified + // recipient. + if info.sender == dao { + for (token_id, owner, preparer) in token_ids_with_owners_and_preparers { + // cancel preparation if it exists + if preparer.is_some() { + PREPARED_ONFTS.remove(deps.storage, token_id.to_string()); + } + + // if this contract owns the NFT, send it to the recipient (or + // preparer if one exists and no recipient was specified). + if owner == env.contract.address { + let recipient = recipient + .clone() + .or_else(|| preparer.map(|p| p.to_string())); + + if let Some(recipient) = recipient { + transfer_msgs.push(get_onft_transfer_msg( + &config.onft_collection_id, + token_id, + env.contract.address.as_str(), + &recipient, + )); + } else { + return Err(ContractError::NoRecipient {}); + } + } + } + } else { + for (token_id, owner, preparer) in token_ids_with_owners_and_preparers { + let is_preparer = preparer.as_ref().map_or(false, |p| *p == info.sender); + // only owner or preparer can cancel stake + if info.sender != owner && !is_preparer { + return Err(ContractError::NotPreparerNorOwner {}); + } + + // cancel preparation + PREPARED_ONFTS.remove(deps.storage, token_id.to_string()); + + // if owner is this staking contract, send it back to the preparer, + // who must also be the sender (but let's force unwrap the preparer + // just to make sure) + if owner == env.contract.address { + transfer_msgs.push(get_onft_transfer_msg( + &config.onft_collection_id, + token_id, + env.contract.address.as_str(), + preparer.unwrap().as_ref(), + )); + } + } + } + + Ok(Response::default() + .add_messages(transfer_msgs) + .add_attribute("action", "cancel_stake") + .add_attribute("sender", info.sender) + .add_attribute("token_ids", token_ids.join(",")) + .add_attribute( + "recipient", + recipient.unwrap_or_else(|| "_none".to_string()), + )) +} + +pub fn execute_unstake( + deps: DepsMut, + env: Env, + info: MessageInfo, + token_ids: Vec, +) -> Result { + if token_ids.is_empty() { + return Err(ContractError::ZeroUnstake {}); + } + + register_unstaked_nfts(deps.storage, env.block.height, &info.sender, &token_ids)?; + + // Provided that the backing cw721 contract is non-malicious: + // + // 1. no token that has been staked may be staked again before + // first being unstaked. + // + // Provided that the other methods on this contract are functional: + // + // 2. there will never exist a pending claim for a token that is + // unstaked. + // 3. (6) => claims may only be created for tokens that are staked. + // 4. (1) && (2) && (3) => there will never be a staked NFT for + // which there is also a pending claim. + // + // (aside: the requirement on (1) for (4) may be confusing. it is + // needed because if a token could be staked more than once, a + // token could be staked, moved into the claims queue, and then + // staked again, in which case the token is both staked and has a + // pending claim.) + // + // If we reach this point in execution, `register_unstaked_nfts` + // has not errored and thus: + // + // 5. token_ids contains no duplicate values. + // 6. all NFTs in token_ids were staked by `info.sender` + // 7. (4) && (6) => none of the tokens in token_ids are in the + // claims queue for `info.sender` + // + // (5) && (7) are the invariants for calling `create_nft_claims` + // so if we reach this point in execution, we may safely create + // claims. + + let hook_msgs = + unstake_nft_hook_msgs(HOOKS, deps.storage, info.sender.clone(), token_ids.clone())?; + + let config = CONFIG.load(deps.storage)?; + match config.unstaking_duration { + None => { + let return_messages = token_ids + .into_iter() + .map(|token_id| -> CosmosMsg { + get_onft_transfer_msg( + &config.onft_collection_id, + &token_id, + env.contract.address.as_str(), + info.sender.as_str(), + ) + }) + .collect::>(); + + Ok(Response::default() + .add_messages(return_messages) + .add_submessages(hook_msgs) + .add_attribute("action", "unstake") + .add_attribute("from", info.sender) + .add_attribute("claim_duration", "None")) + } + + Some(duration) => { + let outstanding_claims = NFT_CLAIMS + .query_claims(deps.as_ref(), &info.sender)? + .nft_claims; + if outstanding_claims.len() + token_ids.len() > MAX_CLAIMS as usize { + return Err(ContractError::TooManyClaims {}); + } + + // Out of gas here is fine - just try again with fewer + // tokens. + NFT_CLAIMS.create_nft_claims( + deps.storage, + &info.sender, + token_ids, + duration.after(&env.block), + )?; + + Ok(Response::default() + .add_attribute("action", "unstake") + .add_submessages(hook_msgs) + .add_attribute("from", info.sender) + .add_attribute("claim_duration", format!("{duration}"))) + } + } +} + +pub fn execute_claim_nfts( + deps: DepsMut, + env: Env, + info: MessageInfo, +) -> Result { + let nfts = NFT_CLAIMS.claim_nfts(deps.storage, &info.sender, &env.block)?; + if nfts.is_empty() { + return Err(ContractError::NothingToClaim {}); + } + + let config = CONFIG.load(deps.storage)?; + + let msgs = nfts + .into_iter() + .map(|nft| -> CosmosMsg { + get_onft_transfer_msg( + &config.onft_collection_id, + &nft, + env.contract.address.as_str(), + info.sender.as_str(), + ) + }) + .collect::>(); + + Ok(Response::default() + .add_messages(msgs) + .add_attribute("action", "claim_nfts") + .add_attribute("from", info.sender)) +} + +pub fn execute_update_config( + info: MessageInfo, + deps: DepsMut, + duration: Option, +) -> Result { + let mut config: Config = CONFIG.load(deps.storage)?; + let dao = DAO.load(deps.storage)?; + + // Only the DAO can update the config. + if info.sender != dao { + return Err(ContractError::Unauthorized {}); + } + + // Validate unstaking duration + validate_duration(duration)?; + + config.unstaking_duration = duration; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::default() + .add_attribute("action", "update_config") + .add_attribute( + "unstaking_duration", + config + .unstaking_duration + .map(|d| d.to_string()) + .unwrap_or_else(|| "none".to_string()), + )) +} + +pub fn execute_add_hook( + deps: DepsMut, + info: MessageInfo, + addr: String, +) -> Result { + let dao = DAO.load(deps.storage)?; + + // Only the DAO can add a hook + if info.sender != dao { + return Err(ContractError::Unauthorized {}); + } + + let hook = deps.api.addr_validate(&addr)?; + HOOKS.add_hook(deps.storage, hook)?; + + Ok(Response::default() + .add_attribute("action", "add_hook") + .add_attribute("hook", addr)) +} + +pub fn execute_remove_hook( + deps: DepsMut, + info: MessageInfo, + addr: String, +) -> Result { + let dao = DAO.load(deps.storage)?; + + // Only the DAO can remove a hook + if info.sender != dao { + return Err(ContractError::Unauthorized {}); + } + + let hook = deps.api.addr_validate(&addr)?; + HOOKS.remove_hook(deps.storage, hook)?; + + Ok(Response::default() + .add_attribute("action", "remove_hook") + .add_attribute("hook", addr)) +} + +pub fn execute_update_active_threshold( + deps: DepsMut, + _env: Env, + info: MessageInfo, + new_active_threshold: Option, +) -> Result { + let dao = DAO.load(deps.storage)?; + if info.sender != dao { + return Err(ContractError::Unauthorized {}); + } + + let config = CONFIG.load(deps.storage)?; + if let Some(active_threshold) = new_active_threshold { + match active_threshold { + ActiveThreshold::Percentage { percent } => { + assert_valid_percentage_threshold(percent)?; + } + ActiveThreshold::AbsoluteCount { count } => { + let nft_supply = query_onft_supply(deps.as_ref(), &config.onft_collection_id)?; + assert_valid_absolute_count_threshold(count, Uint128::new(nft_supply.into()))?; + } + } + ACTIVE_THRESHOLD.save(deps.storage, &active_threshold)?; + } else { + ACTIVE_THRESHOLD.remove(deps.storage); + } + + Ok(Response::new().add_attribute("action", "update_active_threshold")) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::ActiveThreshold {} => query_active_threshold(deps), + QueryMsg::Config {} => query_config(deps), + QueryMsg::Dao {} => query_dao(deps), + QueryMsg::Info {} => query_info(deps), + QueryMsg::IsActive {} => query_is_active(deps, env), + QueryMsg::NftClaims { address } => query_nft_claims(deps, address), + QueryMsg::Hooks {} => query_hooks(deps), + QueryMsg::StakedNfts { + address, + start_after, + limit, + } => query_staked_nfts(deps, address, start_after, limit), + QueryMsg::TotalPowerAtHeight { height } => query_total_power_at_height(deps, env, height), + QueryMsg::VotingPowerAtHeight { address, height } => { + query_voting_power_at_height(deps, env, address, height) + } + } +} + +pub fn query_active_threshold(deps: Deps) -> StdResult { + to_json_binary(&ActiveThresholdResponse { + active_threshold: ACTIVE_THRESHOLD.may_load(deps.storage)?, + }) +} + +pub fn query_is_active(deps: Deps, env: Env) -> StdResult { + let threshold = ACTIVE_THRESHOLD.may_load(deps.storage)?; + if let Some(threshold) = threshold { + let config = CONFIG.load(deps.storage)?; + let staked_nfts = TOTAL_STAKED_NFTS + .may_load_at_height(deps.storage, env.block.height)? + .unwrap_or_default(); + let total_nfts = query_onft_supply(deps, &config.onft_collection_id)?; + + match threshold { + ActiveThreshold::AbsoluteCount { count } => to_json_binary(&IsActiveResponse { + active: staked_nfts >= count, + }), + ActiveThreshold::Percentage { percent } => { + // Check if there are any staked NFTs + if staked_nfts.is_zero() { + return to_json_binary(&IsActiveResponse { active: false }); + } + + // percent is bounded between [0, 100]. decimal + // represents percents in u128 terms as p * + // 10^15. this bounds percent between [0, 10^17]. + // + // total_potential_power is bounded between [0, 2^64] + // as it tracks the count of NFT tokens which has + // a max supply of 2^64. + // + // with our precision factor being 10^9: + // + // total_nfts <= 2^64 * 10^9 <= 2^256 + // + // so we're good to put that in a u256. + // + // multiply_ratio promotes to a u512 under the hood, + // so it won't overflow, multiplying by a percent less + // than 100 is gonna make something the same size or + // smaller, applied + 10^9 <= 2^128 * 10^9 + 10^9 <= + // 2^256, so the top of the round won't overflow, and + // rounding is rounding down, so the whole thing can + // be safely unwrapped at the end of the day thank you + // for coming to my ted talk. + let total_nfts_count = Uint128::from(total_nfts).full_mul(PRECISION_FACTOR); + + // under the hood decimals are `atomics / 10^decimal_places`. + // cosmwasm doesn't give us a Decimal * Uint256 + // implementation so we take the decimal apart and + // multiply by the fraction. + let applied = total_nfts_count.multiply_ratio( + percent.atomics(), + Uint256::from(10u64).pow(percent.decimal_places()), + ); + let rounded = (applied + Uint256::from(PRECISION_FACTOR) - Uint256::from(1u128)) + / Uint256::from(PRECISION_FACTOR); + let count: Uint128 = rounded.try_into().unwrap(); + + // staked_nfts >= total_nfts * percent + to_json_binary(&IsActiveResponse { + active: staked_nfts >= count, + }) + } + } + } else { + to_json_binary(&IsActiveResponse { active: true }) + } +} + +pub fn query_voting_power_at_height( + deps: Deps, + env: Env, + address: String, + height: Option, +) -> StdResult { + let address = deps.api.addr_validate(&address)?; + let height = height.unwrap_or(env.block.height); + let power = NFT_BALANCES + .may_load_at_height(deps.storage, &address, height)? + .unwrap_or_default(); + to_json_binary(&dao_interface::voting::VotingPowerAtHeightResponse { power, height }) +} + +pub fn query_total_power_at_height(deps: Deps, env: Env, height: Option) -> StdResult { + let height = height.unwrap_or(env.block.height); + let power = TOTAL_STAKED_NFTS + .may_load_at_height(deps.storage, height)? + .unwrap_or_default(); + to_json_binary(&dao_interface::voting::TotalPowerAtHeightResponse { power, height }) +} + +pub fn query_config(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + to_json_binary(&config) +} + +pub fn query_dao(deps: Deps) -> StdResult { + let dao = DAO.load(deps.storage)?; + to_json_binary(&dao) +} + +pub fn query_nft_claims(deps: Deps, address: String) -> StdResult { + to_json_binary(&NFT_CLAIMS.query_claims(deps, &deps.api.addr_validate(&address)?)?) +} + +pub fn query_hooks(deps: Deps) -> StdResult { + to_json_binary(&HOOKS.query_hooks(deps)?) +} + +pub fn query_info(deps: Deps) -> StdResult { + let info = cw2::get_contract_version(deps.storage)?; + to_json_binary(&dao_interface::voting::InfoResponse { info }) +} + +pub fn query_staked_nfts( + deps: Deps, + address: String, + start_after: Option, + limit: Option, +) -> StdResult { + let prefix = deps.api.addr_validate(&address)?; + let prefix = STAKED_NFTS_PER_OWNER.prefix(&prefix); + + let start_after = start_after.as_deref().map(Bound::exclusive); + let range = prefix.keys( + deps.storage, + start_after, + None, + cosmwasm_std::Order::Ascending, + ); + let range: StdResult> = match limit { + Some(l) => range.take(l as usize).collect(), + None => range.collect(), + }; + to_json_binary(&range?) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + let storage_version: ContractVersion = get_contract_version(deps.storage)?; + + // Only migrate if newer + if storage_version.version.as_str() < CONTRACT_VERSION { + // Set contract to version to latest + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + } + + Ok(Response::new().add_attribute("action", "migrate")) +} diff --git a/contracts/voting/dao-voting-onft-staked/src/error.rs b/contracts/voting/dao-voting-onft-staked/src/error.rs new file mode 100644 index 000000000..252e8caf4 --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/error.rs @@ -0,0 +1,48 @@ +use cosmwasm_std::StdError; +use dao_voting::threshold::ActiveThresholdError; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error(transparent)] + Std(#[from] StdError), + + #[error(transparent)] + ActiveThresholdError(#[from] ActiveThresholdError), + + #[error(transparent)] + HookError(#[from] cw_hooks::HookError), + + #[error(transparent)] + UnstakingDurationError(#[from] dao_voting::duration::UnstakingDurationError), + + #[error("Nothing to claim")] + NothingToClaim {}, + + #[error("Only an NFT's owner can prepare it to be staked")] + OnlyOwnerCanPrepareStake {}, + + #[error("NFTs must be prepared and transferred before they can be staked")] + StakeMustBePrepared {}, + + #[error("Recipient must be set when the DAO is cancelling a stake that was not prepared")] + NoRecipient {}, + + #[error("Only the owner or preparer can cancel a prepared stake")] + NotPreparerNorOwner {}, + + #[error("Can not unstake that which you have not staked (unstaking {token_id})")] + NotStaked { token_id: String }, + + #[error("Too many outstanding claims. Claim some tokens before unstaking more.")] + TooManyClaims {}, + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Got a submessage reply with unknown id: {id}")] + UnknownReplyId { id: u64 }, + + #[error("Can't unstake zero NFTs.")] + ZeroUnstake {}, +} diff --git a/contracts/voting/dao-voting-onft-staked/src/lib.rs b/contracts/voting/dao-voting-onft-staked/src/lib.rs new file mode 100644 index 000000000..955654f05 --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/lib.rs @@ -0,0 +1,12 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod contract; +mod error; +pub mod msg; +mod omniflix; +pub mod state; + +#[cfg(test)] +mod testing; + +pub use crate::error::ContractError; diff --git a/contracts/voting/dao-voting-onft-staked/src/msg.rs b/contracts/voting/dao-voting-onft-staked/src/msg.rs new file mode 100644 index 000000000..08e589bc5 --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/msg.rs @@ -0,0 +1,119 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cw_utils::Duration; +use dao_dao_macros::{active_query, voting_module_query}; +use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; + +#[cw_serde] +#[allow(clippy::large_enum_variant)] +pub enum OnftCollection { + /// Uses an existing x/onft denom/collection. + Existing { + /// ID of an already created x/onft denom/collection. + id: String, + }, +} + +#[cw_serde] +pub struct InstantiateMsg { + /// ONFT collection that will be staked. + pub onft_collection: OnftCollection, + /// Amount of time between unstaking and tokens being available. To unstake + /// with no delay, leave as `None`. + pub unstaking_duration: Option, + /// The number or percentage of tokens that must be staked for the DAO to be + /// active + pub active_threshold: Option, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Step 1/3 of the NFT staking process. x/onft doesn't support executing a + /// smart contract on NFT transfer like cw721s do, so the stake process is + /// broken up: + /// 1. The sender calls `PrepareStake` to inform this staking contract of + /// the NFTs that are about to be staked. This will succeed only if the + /// sender currently owns the NFT(s). + /// 2. The sender then transfers the NFT(s) to the staking contract. + /// 3. The sender calls `ConfirmStake` on this staking contract which + /// confirms the NFTs were transferred to it and registers the stake. + /// + /// PrepareStake overrides any previous PrepareStake calls, as long as the + /// sender owns the NFT(s). + PrepareStake { token_ids: Vec }, + /// Step 3/3 of the NFT staking process. x/onft doesn't support executing a + /// smart contract on NFT transfer like cw721s do, so the stake process is + /// broken up: + /// 1. The sender calls `PrepareStake` to inform this staking contract of + /// the NFTs that are about to be staked. This will succeed only if the + /// sender currently owns the NFT(s). + /// 2. The sender then transfers the NFT(s) to the staking contract. + /// 3. The sender calls `ConfirmStake` on this staking contract which + /// confirms the NFTs were transferred to it and registers the stake. + ConfirmStake { token_ids: Vec }, + /// CancelStake serves as an undo function in case an NFT or stake gets into + /// a bad state, either because the stake process was never completed, or + /// because someone sent an NFT to the staking contract without preparing + /// the stake first. + /// + /// If called by: + /// - the original stake preparer, the preparation will be canceled, and the + /// NFT(s) will be sent back if the staking contract owns them. + /// - the current NFT(s) owner, the preparation will be canceled, if any. + /// - the DAO, the preparation will be canceled (if any exists), and the + /// NFT(s) will be sent to the specified recipient (if the staking + /// contract owns them). if no recipient is specified but the NFT was + /// prepared, it will be sent back to the preparer. + /// + /// The recipient field only applies when the sender is the DAO. In the + /// other cases, the NFT(s) will always be sent back to the sender. Note: if + /// the NFTs were sent to the staking contract, but no stake was prepared, + /// only the DAO will be able to correct this and send them somewhere. + CancelStake { + token_ids: Vec, + recipient: Option, + }, + /// Unstakes the specified token_ids on behalf of the sender. token_ids must + /// have unique values and have non-zero length. + Unstake { token_ids: Vec }, + /// Claim NFTs that have been unstaked for the specified duration. + ClaimNfts {}, + /// Updates the contract configuration, namely unstaking duration. Only + /// callable by the DAO that initialized this voting contract. + UpdateConfig { duration: Option }, + /// Adds a hook which is called on staking / unstaking events. Only callable + /// by the DAO that initialized this voting contract. + AddHook { addr: String }, + /// Removes a hook which is called on staking / unstaking events. Only + /// callable by the DAO that initialized this voting contract. + RemoveHook { addr: String }, + /// Sets the active threshold to a new value. Only callable by the DAO that + /// initialized this voting contract. + UpdateActiveThreshold { + new_threshold: Option, + }, +} + +#[active_query] +#[voting_module_query] +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(crate::state::Config)] + Config {}, + #[returns(::cw721_controllers::NftClaimsResponse)] + NftClaims { address: String }, + #[returns(::cw_controllers::HooksResponse)] + Hooks {}, + // List the staked NFTs for a given address. + #[returns(Vec)] + StakedNfts { + address: String, + start_after: Option, + limit: Option, + }, + #[returns(ActiveThresholdResponse)] + ActiveThreshold {}, +} + +#[cw_serde] +pub struct MigrateMsg {} diff --git a/contracts/voting/dao-voting-onft-staked/src/omniflix.rs b/contracts/voting/dao-voting-onft-staked/src/omniflix.rs new file mode 100644 index 000000000..fe018ef0e --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/omniflix.rs @@ -0,0 +1,32 @@ +use cosmwasm_std::{CosmosMsg, Deps, StdError, StdResult}; +use omniflix_std::types::omniflix::onft::v1beta1::{MsgTransferOnft, OnftQuerier}; + +pub fn query_onft_owner(deps: Deps, denom_id: &str, token_id: &str) -> StdResult { + let res = OnftQuerier::new(&deps.querier).onft(denom_id.to_string(), token_id.to_string())?; + let owner = res + .onft + .ok_or(StdError::generic_err("ONFT not found"))? + .owner; + + Ok(owner) +} + +pub fn query_onft_supply(deps: Deps, id: &str) -> StdResult { + let res = OnftQuerier::new(&deps.querier).supply(id.to_string(), "".to_string())?; + Ok(res.amount) +} + +pub fn get_onft_transfer_msg( + denom_id: &str, + token_id: &str, + sender: &str, + recipient: &str, +) -> CosmosMsg { + MsgTransferOnft { + denom_id: denom_id.to_string(), + id: token_id.to_string(), + sender: sender.to_string(), + recipient: recipient.to_string(), + } + .into() +} diff --git a/contracts/voting/dao-voting-onft-staked/src/state.rs b/contracts/voting/dao-voting-onft-staked/src/state.rs new file mode 100644 index 000000000..0e85822ab --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/state.rs @@ -0,0 +1,118 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Empty, StdError, StdResult, Storage, Uint128}; +use cw721_controllers::NftClaims; +use cw_hooks::Hooks; +use cw_storage_plus::{Item, Map, SnapshotItem, SnapshotMap, Strategy}; +use cw_utils::Duration; +use dao_voting::threshold::ActiveThreshold; + +use crate::ContractError; + +#[cw_serde] +pub struct Config { + pub onft_collection_id: String, + pub unstaking_duration: Option, +} + +pub const ACTIVE_THRESHOLD: Item = Item::new("active_threshold"); +pub const CONFIG: Item = Item::new("config"); +pub const DAO: Item = Item::new("dao"); + +/// NFTs prepared to be staked. The owner must prepare the NFT before +/// transferring and staking so the contract can verify them as the rightful +/// owner before staking. Since ONFT transfer actions cannot include a message +/// to execute on transfer, we can't verify who sent an ONFT, so we have to +/// prepare it first. Once a stake is confirmed, the prepared stake is removed. +/// +/// Map token ID to validated preparer. +pub const PREPARED_ONFTS: Map = Map::new("po"); + +/// The set of NFTs currently staked by each address. The existence of +/// an `(address, token_id)` pair implies that `address` has staked +/// `token_id`. +pub const STAKED_NFTS_PER_OWNER: Map<(&Addr, &str), Empty> = Map::new("snpw"); +/// The number of NFTs staked by an address as a function of block +/// height. +pub const NFT_BALANCES: SnapshotMap<&Addr, Uint128> = SnapshotMap::new( + "nb", + "nb__checkpoints", + "nb__changelog", + Strategy::EveryBlock, +); +/// The number of NFTs staked with this contract as a function of +/// block height. +pub const TOTAL_STAKED_NFTS: SnapshotItem = SnapshotItem::new( + "tsn", + "tsn__checkpoints", + "tsn__changelog", + Strategy::EveryBlock, +); + +/// The maximum number of claims that may be outstanding. +pub const MAX_CLAIMS: u64 = 70; +pub const NFT_CLAIMS: NftClaims = NftClaims::new("nft_claims"); + +// Hooks to contracts that will receive staking and unstaking +// messages. +pub const HOOKS: Hooks = Hooks::new("hooks"); + +pub fn register_staked_nfts( + storage: &mut dyn Storage, + height: u64, + staker: &Addr, + token_ids: &Vec, +) -> StdResult<()> { + let count = token_ids.len() as u128; + let add_count = |prev: Option| -> StdResult { + prev.unwrap_or_default() + .checked_add(Uint128::new(count)) + .map_err(StdError::overflow) + }; + + for token_id in token_ids { + PREPARED_ONFTS.remove(storage, token_id.to_string()); + STAKED_NFTS_PER_OWNER.save(storage, (staker, token_id), &Empty::default())?; + } + + NFT_BALANCES.update(storage, staker, height, add_count)?; + TOTAL_STAKED_NFTS + .update(storage, height, add_count) + .map(|_| ()) +} + +/// Registers the unstaking of TOKEN_IDs in storage. Errors if: +/// +/// 1. `token_ids` is non-unique. +/// 2. a NFT being staked has not previously been staked. +pub fn register_unstaked_nfts( + storage: &mut dyn Storage, + height: u64, + staker: &Addr, + token_ids: &[String], +) -> Result<(), ContractError> { + let subtractor = |amount: u128| { + move |prev: Option| -> StdResult { + prev.expect("unstaking that which was not staked") + .checked_sub(Uint128::new(amount)) + .map_err(StdError::overflow) + } + }; + + for token in token_ids { + let key = (staker, token.as_str()); + if STAKED_NFTS_PER_OWNER.has(storage, key) { + STAKED_NFTS_PER_OWNER.remove(storage, key); + } else { + return Err(ContractError::NotStaked { + token_id: token.clone(), + }); + } + } + + // invariant: token_ids has unique values. for loop asserts this. + + let sub_n = subtractor(token_ids.len() as u128); + TOTAL_STAKED_NFTS.update(storage, height, sub_n)?; + NFT_BALANCES.update(storage, staker, height, sub_n)?; + Ok(()) +} diff --git a/contracts/voting/dao-voting-onft-staked/src/testing/app.rs b/contracts/voting/dao-voting-onft-staked/src/testing/app.rs new file mode 100644 index 000000000..951d157a9 --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/testing/app.rs @@ -0,0 +1,61 @@ +use std::ops::{Deref, DerefMut}; + +use crate::testing::omniflix_stargate::StargateKeeper; +use cosmwasm_std::{testing::MockApi, Empty, GovMsg, IbcMsg, IbcQuery, MemoryStorage}; +use cw_multi_test::{ + no_init, App, AppBuilder, BankKeeper, DistributionKeeper, FailingModule, StakeKeeper, + WasmKeeper, +}; +#[allow(clippy::type_complexity)] +pub struct OmniflixApp( + App< + BankKeeper, + MockApi, + MemoryStorage, + FailingModule, + WasmKeeper, + StakeKeeper, + DistributionKeeper, + FailingModule, + FailingModule, + StargateKeeper, + >, +); +impl Deref for OmniflixApp { + type Target = App< + BankKeeper, + MockApi, + MemoryStorage, + FailingModule, + WasmKeeper, + StakeKeeper, + DistributionKeeper, + FailingModule, + FailingModule, + StargateKeeper, + >; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for OmniflixApp { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} +impl Default for OmniflixApp { + fn default() -> Self { + Self::new() + } +} + +impl OmniflixApp { + pub fn new() -> Self { + let app_builder = AppBuilder::default(); + let stargate = StargateKeeper {}; + let app = app_builder.with_stargate(stargate).build(no_init); + OmniflixApp(app) + } +} diff --git a/contracts/voting/dao-voting-onft-staked/src/testing/execute.rs b/contracts/voting/dao-voting-onft-staked/src/testing/execute.rs new file mode 100644 index 000000000..fd79fd25e --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/testing/execute.rs @@ -0,0 +1,236 @@ +use crate::msg::ExecuteMsg; +use anyhow::Result as AnyResult; +use cosmwasm_std::Addr; +use cw_multi_test::AppResponse; +use cw_multi_test::Executor; +use cw_utils::Duration; +use omniflix_std::types::omniflix::onft::v1beta1::{MsgCreateDenom, MsgMintOnft, MsgTransferOnft}; + +use super::app::OmniflixApp; +use super::DAO; + +// Shorthand for an unchecked address. +macro_rules! addr { + ($x:expr ) => { + Addr::unchecked($x) + }; +} + +pub fn create_onft_collection( + app: &mut OmniflixApp, + id: &str, + sender: &str, + minter: &str, +) -> String { + app.execute( + addr!(sender), + MsgCreateDenom { + id: id.to_string(), + symbol: "BAD".to_string(), + name: "Bad Kids".to_string(), + description: "bad kids".to_string(), + preview_uri: "".to_string(), + schema: "".to_string(), + sender: minter.to_string(), + creation_fee: None, + uri: "".to_string(), + uri_hash: "".to_string(), + data: "".to_string(), + royalty_receivers: vec![], + } + .into(), + ) + .unwrap(); + + id.to_string() +} + +pub fn mint_nft( + app: &mut OmniflixApp, + collection_id: &str, + receiver: &str, + token_id: &str, +) -> AnyResult { + app.execute( + addr!(DAO), + MsgMintOnft { + id: token_id.to_string(), + denom_id: collection_id.to_string(), + metadata: None, + data: "".to_string(), + transferable: true, + extensible: false, + nsfw: false, + royalty_share: "".to_string(), + sender: DAO.to_string(), + recipient: receiver.to_string(), + } + .into(), + ) +} + +pub fn send_nft( + app: &mut OmniflixApp, + collection_id: &str, + token_id: &str, + sender: &str, + recipient: &str, +) -> AnyResult { + app.execute( + addr!(sender), + MsgTransferOnft { + denom_id: collection_id.to_string(), + id: token_id.to_string(), + sender: sender.to_string(), + recipient: recipient.to_string(), + } + .into(), + ) +} + +pub fn prepare_stake_nft( + app: &mut OmniflixApp, + module: &Addr, + sender: &str, + token_id: &str, +) -> AnyResult { + app.execute_contract( + addr!(sender), + module.clone(), + &ExecuteMsg::PrepareStake { + token_ids: vec![token_id.to_string()], + }, + &[], + ) +} + +pub fn confirm_stake_nft( + app: &mut OmniflixApp, + module: &Addr, + sender: &str, + token_id: &str, +) -> AnyResult { + app.execute_contract( + addr!(sender), + module.clone(), + &ExecuteMsg::ConfirmStake { + token_ids: vec![token_id.to_string()], + }, + &[], + ) +} + +pub fn stake_nft( + app: &mut OmniflixApp, + collection_id: &str, + module: &Addr, + sender: &str, + token_id: &str, +) -> AnyResult<()> { + prepare_stake_nft(app, module, sender, token_id)?; + send_nft(app, collection_id, token_id, sender, module.as_str())?; + confirm_stake_nft(app, module, sender, token_id)?; + Ok(()) +} + +pub fn cancel_stake( + app: &mut OmniflixApp, + module: &Addr, + sender: &str, + token_id: &str, + recipient: Option<&str>, +) -> AnyResult { + app.execute_contract( + addr!(sender), + module.clone(), + &ExecuteMsg::CancelStake { + token_ids: vec![token_id.to_string()], + recipient: recipient.map(|s| s.to_string()), + }, + &[], + ) +} + +pub fn mint_and_stake_nft( + app: &mut OmniflixApp, + collection_id: &str, + module: &Addr, + staker: &str, + token_id: &str, +) -> AnyResult<()> { + mint_nft(app, collection_id, staker, token_id)?; + stake_nft(app, collection_id, module, staker, token_id)?; + + Ok(()) +} + +pub fn unstake_nfts( + app: &mut OmniflixApp, + module: &Addr, + sender: &str, + token_ids: &[&str], +) -> AnyResult { + app.execute_contract( + addr!(sender), + module.clone(), + &ExecuteMsg::Unstake { + token_ids: token_ids.iter().map(|s| s.to_string()).collect(), + }, + &[], + ) +} + +pub fn update_config( + app: &mut OmniflixApp, + module: &Addr, + sender: &str, + duration: Option, +) -> AnyResult { + app.execute_contract( + addr!(sender), + module.clone(), + &ExecuteMsg::UpdateConfig { duration }, + &[], + ) +} + +pub fn claim_nfts(app: &mut OmniflixApp, module: &Addr, sender: &str) -> AnyResult { + app.execute_contract( + addr!(sender), + module.clone(), + &ExecuteMsg::ClaimNfts {}, + &[], + ) +} + +pub fn add_hook( + app: &mut OmniflixApp, + module: &Addr, + sender: &str, + hook: &str, +) -> AnyResult { + app.execute_contract( + addr!(sender), + module.clone(), + &ExecuteMsg::AddHook { + addr: hook.to_string(), + }, + &[], + ) +} + +pub fn remove_hook( + app: &mut OmniflixApp, + module: &Addr, + sender: &str, + hook: &str, +) -> AnyResult { + app.execute_contract( + addr!(sender), + module.clone(), + &ExecuteMsg::RemoveHook { + addr: hook.to_string(), + }, + &[], + ) +} diff --git a/contracts/voting/dao-voting-onft-staked/src/testing/hooks.rs b/contracts/voting/dao-voting-onft-staked/src/testing/hooks.rs new file mode 100644 index 000000000..b32d35398 --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/testing/hooks.rs @@ -0,0 +1,110 @@ +use cosmwasm_std::{ + testing::{mock_dependencies, mock_env, mock_info}, + Addr, +}; +use dao_hooks::nft_stake::{stake_nft_hook_msgs, unstake_nft_hook_msgs}; + +use crate::{ + contract::execute, + state::{Config, CONFIG, DAO, HOOKS}, +}; + +#[test] +fn test_hooks() { + let mut deps = mock_dependencies(); + + let messages = stake_nft_hook_msgs( + HOOKS, + &deps.storage, + Addr::unchecked("ekez"), + "ekez-token".to_string(), + ) + .unwrap(); + assert_eq!(messages.len(), 0); + + let messages = unstake_nft_hook_msgs( + HOOKS, + &deps.storage, + Addr::unchecked("ekez"), + vec!["ekez-token".to_string()], + ) + .unwrap(); + assert_eq!(messages.len(), 0); + + // Save a DAO address for the execute messages we're testing. + DAO.save(deps.as_mut().storage, &Addr::unchecked("ekez")) + .unwrap(); + + // Save a config for the execute messages we're testing. + CONFIG + .save( + deps.as_mut().storage, + &Config { + onft_collection_id: "ekez-token".to_string(), + unstaking_duration: None, + }, + ) + .unwrap(); + + let env = mock_env(); + let info = mock_info("ekez", &[]); + + execute( + deps.as_mut(), + env, + info, + crate::msg::ExecuteMsg::AddHook { + addr: "ekez".to_string(), + }, + ) + .unwrap(); + + let messages = stake_nft_hook_msgs( + HOOKS, + &deps.storage, + Addr::unchecked("ekez"), + "ekez-token".to_string(), + ) + .unwrap(); + assert_eq!(messages.len(), 1); + + let messages = unstake_nft_hook_msgs( + HOOKS, + &deps.storage, + Addr::unchecked("ekez"), + vec!["ekez-token".to_string()], + ) + .unwrap(); + assert_eq!(messages.len(), 1); + + let env = mock_env(); + let info = mock_info("ekez", &[]); + + execute( + deps.as_mut(), + env, + info, + crate::msg::ExecuteMsg::RemoveHook { + addr: "ekez".to_string(), + }, + ) + .unwrap(); + + let messages = stake_nft_hook_msgs( + HOOKS, + &deps.storage, + Addr::unchecked("ekez"), + "ekez-token".to_string(), + ) + .unwrap(); + assert_eq!(messages.len(), 0); + + let messages = unstake_nft_hook_msgs( + HOOKS, + &deps.storage, + Addr::unchecked("ekez"), + vec!["ekez-token".to_string()], + ) + .unwrap(); + assert_eq!(messages.len(), 0); +} diff --git a/contracts/voting/dao-voting-onft-staked/src/testing/mod.rs b/contracts/voting/dao-voting-onft-staked/src/testing/mod.rs new file mode 100644 index 000000000..487a4dde0 --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/testing/mod.rs @@ -0,0 +1,73 @@ +mod app; +mod execute; +mod hooks; +mod omniflix_stargate; +mod queries; +mod tests; + +use app::OmniflixApp; +use cosmwasm_std::Addr; +use cw_multi_test::Executor; +use cw_utils::Duration; +use dao_testing::contracts::onft_staked_voting_contract; +use dao_voting::threshold::ActiveThreshold; + +use crate::msg::{InstantiateMsg, OnftCollection}; + +use self::execute::create_onft_collection; + +/// Address used as the instantiator. +pub(crate) const DAO: &str = "dao"; +/// Address used to stake. +pub(crate) const STAKER: &str = "staker"; + +pub(crate) struct CommonTest { + app: OmniflixApp, + module_id: u64, + module: Addr, + nft: String, +} + +pub(crate) fn setup_test( + unstaking_duration: Option, + active_threshold: Option, +) -> CommonTest { + let mut app = OmniflixApp::new(); + let module_id = app.store_code(onft_staked_voting_contract()); + + let nft = create_onft_collection(&mut app, "nft", DAO, DAO); + let module = app + .instantiate_contract( + module_id, + Addr::unchecked(DAO), + &InstantiateMsg { + onft_collection: OnftCollection::Existing { + id: nft.to_string(), + }, + unstaking_duration, + active_threshold, + }, + &[], + "onft_voting", + None, + ) + .unwrap(); + + CommonTest { + app, + module_id, + module, + nft, + } +} + +// Advantage to using a macro for this is that the error trace links +// to the exact line that the error occured, instead of inside of a +// function where the assertion would otherwise happen. +macro_rules! is_error { + ($x:expr => $e:tt) => { + assert!(format!("{:#}", $x.unwrap_err()).contains($e)) + }; +} + +pub(crate) use is_error; diff --git a/contracts/voting/dao-voting-onft-staked/src/testing/omniflix_stargate.rs b/contracts/voting/dao-voting-onft-staked/src/testing/omniflix_stargate.rs new file mode 100644 index 000000000..baa249998 --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/testing/omniflix_stargate.rs @@ -0,0 +1,144 @@ +use anyhow::Result; +use cosmwasm_std::{from_json, to_json_binary, Addr, Api, Binary, BlockInfo, Querier, Storage}; +use cw_multi_test::{error::AnyResult, AppResponse, CosmosRouter, Stargate}; +use omniflix_std::types::omniflix::onft::v1beta1::{ + Collection, Denom, MsgCreateDenom, MsgCreateDenomResponse, MsgMintOnft, MsgMintOnftResponse, + MsgTransferOnft, MsgTransferOnftResponse, QuerySupplyRequest, QuerySupplyResponse, +}; +use omniflix_std::types::omniflix::onft::v1beta1::{Onft, QueryOnftRequest, QueryOnftResponse}; +use prost::{DecodeError, Message}; + +const COLLECTION_PREFIX: &str = "collection"; + +pub struct StargateKeeper {} + +impl StargateKeeper {} + +impl Stargate for StargateKeeper { + fn execute( + &self, + _api: &dyn Api, + storage: &mut dyn Storage, + _router: &dyn CosmosRouter, + _block: &BlockInfo, + sender: Addr, + type_url: String, + value: Binary, + ) -> AnyResult { + if type_url == *"/OmniFlix.onft.v1beta1.MsgCreateDenom" { + let msg: MsgCreateDenom = Message::decode(value.as_slice()).unwrap(); + let collection = Collection { + denom: Some(Denom { + creator: sender.to_string(), + data: msg.data, + name: msg.name, + id: msg.id.clone(), + preview_uri: msg.preview_uri, + description: msg.description, + schema: msg.schema, + symbol: msg.symbol, + uri: msg.uri, + uri_hash: msg.uri_hash, + royalty_receivers: msg.royalty_receivers, + }), + onfts: vec![], + }; + let key = format!("collections:{}:{}", COLLECTION_PREFIX, msg.id); + let serialized_collection = + to_json_binary(&collection).expect("Failed to serialize Collection"); + storage.set(key.as_bytes(), &serialized_collection); + + return Ok(AppResponse { + events: vec![], + data: Some(Binary::from(MsgCreateDenomResponse {})), + }); + } + if type_url == *"/OmniFlix.onft.v1beta1.MsgMintONFT" { + let msg: MsgMintOnft = Message::decode(value.as_slice()).unwrap(); + let key = format!("collections:{}:{}", COLLECTION_PREFIX, msg.denom_id.clone()); + let serialized_collection = storage.get(key.as_bytes()); + let mut collection: Collection = from_json(serialized_collection.unwrap()) + .expect("Failed to deserialize Collection"); + let onft = Onft { + id: msg.id, + created_at: None, + nsfw: msg.nsfw, + owner: msg.recipient, + data: msg.data, + transferable: msg.transferable, + extensible: msg.extensible, + metadata: msg.metadata, + royalty_share: msg.royalty_share, + }; + collection.onfts.push(onft); + let serialized_collection = + to_json_binary(&collection).expect("Failed to serialize Collection"); + storage.set(key.as_bytes(), &serialized_collection); + + return Ok(AppResponse { + events: vec![], + data: Some(Binary::from(MsgMintOnftResponse {})), + }); + } + if type_url == *"/OmniFlix.onft.v1beta1.MsgTransferONFT" { + let parsed_msg: Result = + Message::decode(value.as_slice()); + if let Ok(msg) = parsed_msg { + let key = format!("collections:{}:{}", COLLECTION_PREFIX, msg.denom_id.clone()); + let serialized_collection = storage.get(key.as_bytes()); + let mut collection: Collection = from_json(serialized_collection.unwrap()) + .expect("Failed to deserialize Collection"); + let onft = collection.onfts.iter_mut().find(|onft| onft.id == msg.id); + let onft = onft.unwrap(); + onft.owner = msg.recipient; + let serialized_collection = + to_json_binary(&collection).expect("Failed to serialize Collection"); + storage.set(key.as_bytes(), &serialized_collection); + + return Ok(AppResponse { + events: vec![], + data: Some(Binary::from(MsgTransferOnftResponse {})), + }); + }; + } + Ok(AppResponse::default()) + } + + fn query( + &self, + _api: &dyn Api, + storage: &dyn Storage, + _querier: &dyn Querier, + _block: &BlockInfo, + path: String, + data: Binary, + ) -> AnyResult { + if path == *"/OmniFlix.onft.v1beta1.Query/ONFT" { + let request: QueryOnftRequest = Message::decode(data.as_slice()).unwrap(); + + let key = format!("collections:{}:{}", COLLECTION_PREFIX, request.denom_id); + let serialized_collection = storage.get(key.as_bytes()); + let collection: Collection = from_json(serialized_collection.unwrap()) + .expect("Failed to deserialize Collection"); + let onft = collection + .onfts + .into_iter() + .find(|onft| onft.id == request.id); + + return Ok(to_json_binary(&QueryOnftResponse { onft })?); + } + if path == *"/OmniFlix.onft.v1beta1.Query/Supply" { + let request: QuerySupplyRequest = Message::decode(data.as_slice()).unwrap(); + + let key = format!("collections:{}:{}", COLLECTION_PREFIX, request.denom_id); + let serialized_collection = storage.get(key.as_bytes()); + let collection: Collection = from_json(serialized_collection.unwrap()) + .expect("Failed to deserialize Collection"); + + return Ok(to_json_binary(&QuerySupplyResponse { + amount: collection.onfts.len() as u64, + })?); + } + Ok(data) + } +} diff --git a/contracts/voting/dao-voting-onft-staked/src/testing/queries.rs b/contracts/voting/dao-voting-onft-staked/src/testing/queries.rs new file mode 100644 index 000000000..3ff1facde --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/testing/queries.rs @@ -0,0 +1,116 @@ +use cosmwasm_std::{Addr, StdResult, Uint128}; +use cw721_controllers::NftClaimsResponse; +use cw_controllers::HooksResponse; +use dao_interface::voting::{ + InfoResponse, TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, +}; +use omniflix_std::types::omniflix::onft::v1beta1::{QueryOnftRequest, QueryOnftResponse}; + +use crate::{msg::QueryMsg, state::Config}; + +use super::app::OmniflixApp; + +pub fn query_config(app: &OmniflixApp, module: &Addr) -> StdResult { + let config = app.wrap().query_wasm_smart(module, &QueryMsg::Config {})?; + Ok(config) +} + +pub fn query_claims(app: &OmniflixApp, module: &Addr, addr: &str) -> StdResult { + let claims = app.wrap().query_wasm_smart( + module, + &QueryMsg::NftClaims { + address: addr.to_string(), + }, + )?; + Ok(claims) +} + +pub fn query_hooks(app: &OmniflixApp, module: &Addr) -> StdResult { + let hooks = app.wrap().query_wasm_smart(module, &QueryMsg::Hooks {})?; + Ok(hooks) +} + +pub fn query_staked_nfts( + app: &OmniflixApp, + module: &Addr, + addr: &str, + start_after: Option, + limit: Option, +) -> StdResult> { + let nfts = app.wrap().query_wasm_smart( + module, + &QueryMsg::StakedNfts { + address: addr.to_string(), + start_after, + limit, + }, + )?; + Ok(nfts) +} + +pub fn query_voting_power( + app: &OmniflixApp, + module: &Addr, + addr: &str, + height: Option, +) -> StdResult { + let power = app.wrap().query_wasm_smart( + module, + &QueryMsg::VotingPowerAtHeight { + address: addr.to_string(), + height, + }, + )?; + Ok(power) +} + +pub fn query_total_power( + app: &OmniflixApp, + module: &Addr, + height: Option, +) -> StdResult { + let power = app + .wrap() + .query_wasm_smart(module, &QueryMsg::TotalPowerAtHeight { height })?; + Ok(power) +} + +pub fn query_dao(app: &OmniflixApp, module: &Addr) -> StdResult { + let dao = app.wrap().query_wasm_smart(module, &QueryMsg::Dao {})?; + Ok(dao) +} + +pub fn query_info(app: &OmniflixApp, module: &Addr) -> StdResult { + let info = app.wrap().query_wasm_smart(module, &QueryMsg::Info {})?; + Ok(info) +} + +pub fn query_total_and_voting_power( + app: &OmniflixApp, + module: &Addr, + addr: &str, + height: Option, +) -> StdResult<(Uint128, Uint128)> { + let total_power = query_total_power(app, module, height)?; + let voting_power = query_voting_power(app, module, addr, height)?; + + Ok((total_power.power, voting_power.power)) +} + +pub fn query_nft_owner( + app: &OmniflixApp, + collection_id: &str, + token_id: &str, +) -> StdResult { + let response: QueryOnftResponse = app + .wrap() + .query( + &QueryOnftRequest { + denom_id: collection_id.to_string(), + id: token_id.to_string(), + } + .into(), + ) + .unwrap(); + Ok(response.onft.unwrap().owner) +} diff --git a/contracts/voting/dao-voting-onft-staked/src/testing/tests.rs b/contracts/voting/dao-voting-onft-staked/src/testing/tests.rs new file mode 100644 index 000000000..cbd807060 --- /dev/null +++ b/contracts/voting/dao-voting-onft-staked/src/testing/tests.rs @@ -0,0 +1,1188 @@ +use cosmwasm_std::testing::{mock_dependencies, mock_env}; +use cosmwasm_std::{Addr, Decimal, Uint128}; +use cw721_controllers::{NftClaim, NftClaimsResponse}; +use cw_multi_test::{next_block, Executor}; +use cw_utils::Duration; +use dao_interface::voting::IsActiveResponse; +use dao_voting::threshold::{ActiveThreshold, ActiveThresholdResponse}; + +use crate::msg::OnftCollection; +use crate::testing::execute::{cancel_stake, confirm_stake_nft, prepare_stake_nft, send_nft}; +use crate::testing::queries::query_dao; +use crate::testing::DAO; +use crate::{ + contract::{migrate, CONTRACT_NAME, CONTRACT_VERSION}, + msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}, + state::MAX_CLAIMS, + testing::{ + execute::{ + claim_nfts, mint_and_stake_nft, mint_nft, stake_nft, unstake_nfts, update_config, + }, + queries::{query_config, query_hooks, query_nft_owner, query_total_and_voting_power}, + }, +}; + +use super::{ + execute::{add_hook, remove_hook}, + is_error, + queries::{query_claims, query_info, query_staked_nfts, query_total_power, query_voting_power}, + setup_test, CommonTest, STAKER, +}; + +// I can stake tokens, voting power and total power is updated one +// block later. +#[test] +fn test_stake_tokens() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + let total_power = query_total_power(&app, &module, None)?; + let voting_power = query_voting_power(&app, &module, STAKER, None)?; + + assert_eq!(total_power.power, Uint128::zero()); + assert_eq!(total_power.height, app.block_info().height); + + assert_eq!(voting_power.power, Uint128::zero()); + assert_eq!(voting_power.height, app.block_info().height); + + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "1")?; + + // Voting powers are not updated until a block has passed. + let (total, personal) = query_total_and_voting_power(&app, &module, STAKER, None)?; + assert!(total.is_zero()); + assert!(personal.is_zero()); + + app.update_block(next_block); + + let (total, personal) = query_total_and_voting_power(&app, &module, STAKER, None)?; + assert_eq!(total, Uint128::new(1)); + assert_eq!(personal, Uint128::new(1)); + + Ok(()) +} + +// I can unstake tokens. Unstaking more than one token at once +// works. I can not unstake a token more than once. I can not unstake +// another addresses' token. Voting power and total power is updated +// when I unstake. +#[test] +fn test_unstake_tokens_no_claims() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + let friend = "friend"; + + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "1")?; + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "2")?; + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "3")?; + + mint_nft(&mut app, &nft, friend, "4")?; + mint_nft(&mut app, &nft, friend, "5")?; + stake_nft(&mut app, &nft, &module, friend, "4")?; + stake_nft(&mut app, &nft, &module, friend, "5")?; + + app.update_block(next_block); + + let (total, personal) = query_total_and_voting_power(&app, &module, STAKER, None)?; + assert_eq!(total, Uint128::new(5)); + assert_eq!(personal, Uint128::new(3)); + + unstake_nfts(&mut app, &module, STAKER, &["1", "2"])?; + + // Voting power is updated when I unstake. Waits a block as it's a + // snapshot map. + let (total, personal) = query_total_and_voting_power(&app, &module, STAKER, None)?; + assert_eq!(total, Uint128::new(5)); + assert_eq!(personal, Uint128::new(3)); + app.update_block(next_block); + let (total, personal) = query_total_and_voting_power(&app, &module, STAKER, None)?; + assert_eq!(total, Uint128::new(3)); + assert_eq!(personal, Uint128::new(1)); + + // I can not unstake tokens I do not own. Anyhow can't figure out + // how to downcast this error so we check for the expected string. + let res = unstake_nfts(&mut app, &module, STAKER, &["4"]); + is_error!(res => "Can not unstake that which you have not staked (unstaking 4)"); + + let res = unstake_nfts(&mut app, &module, STAKER, &["5", "4"]); + is_error!(res => "Can not unstake that which you have not staked (unstaking 5)"); + + let res = unstake_nfts(&mut app, &module, STAKER, &["☯️", "4"]); + is_error!(res => "Can not unstake that which you have not staked (unstaking ☯️)"); + + // I can not unstake tokens more than once. + let res = unstake_nfts(&mut app, &module, STAKER, &["1"]); + is_error!(res => "Can not unstake that which you have not staked (unstaking 1)"); + + Ok(()) +} + +// I cannot unstake zero tokens. +#[test] +fn test_unstake_zero_tokens() -> anyhow::Result<()> { + let CommonTest { + mut app, module, .. + } = setup_test(None, None); + + let res = unstake_nfts(&mut app, &module, STAKER, &[]); + is_error!(res => "Can't unstake zero NFTs."); + + Ok(()) +} + +// I can update the unstaking duration and the owner. Only the owner +// may do this. I can unset the owner. Updating the unstaking duration +// does not impact outstanding claims. +#[test] +fn test_update_config() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(Some(Duration::Height(3)), None); + + // non-DAO cannot update config + let res = update_config(&mut app, &module, STAKER, Some(Duration::Time(1))); + is_error!(res => "Unauthorized"); + + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "1")?; + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "2")?; + + unstake_nfts(&mut app, &module, STAKER, &["1"])?; + + let claims = query_claims(&app, &module, STAKER)?; + assert_eq!( + claims, + NftClaimsResponse { + nft_claims: vec![NftClaim { + token_id: "1".to_string(), + release_at: cw_utils::Expiration::AtHeight(app.block_info().height + 3) + }] + } + ); + + // Update config to invalid duration fails + let err = update_config(&mut app, &module, DAO, Some(Duration::Time(0))).unwrap_err(); + assert_eq!( + err.root_cause().to_string(), + "Invalid unstaking duration, unstaking duration cannot be 0".to_string() + ); + + // Update duration + update_config(&mut app, &module, DAO, Some(Duration::Time(1)))?; + + // Existing claims should remain unchanged. + let claims = query_claims(&app, &module, STAKER)?; + assert_eq!( + claims, + NftClaimsResponse { + nft_claims: vec![NftClaim { + token_id: "1".to_string(), + release_at: cw_utils::Expiration::AtHeight(app.block_info().height + 3) + }] + } + ); + + // New claims should reflect the new unstaking duration. Old ones + // should not. + unstake_nfts(&mut app, &module, STAKER, &["2"])?; + let claims = query_claims(&app, &module, STAKER)?; + assert_eq!( + claims, + NftClaimsResponse { + nft_claims: vec![ + NftClaim { + token_id: "1".to_string(), + release_at: cw_utils::Expiration::AtHeight(app.block_info().height + 3) + }, + NftClaim { + token_id: "2".to_string(), + release_at: Duration::Time(1).after(&app.block_info()) + } + ] + } + ); + + let info = app.block_info(); + app.update_block(|block| { + block.height += 3; + block.time = match Duration::Time(1).after(&info) { + cw_utils::Expiration::AtTime(timestamp) => timestamp, + _ => panic!("there should really be an easier way to do this"), + } + }); + + // Do a claim for good measure. + claim_nfts(&mut app, &module, STAKER)?; + let claims = query_claims(&app, &module, STAKER)?; + assert_eq!(claims, NftClaimsResponse { nft_claims: vec![] }); + + Ok(()) +} + +// I can query my pending claims. Attempting to claim with nothing to +// claim results in an error. Attempting to claim with tokens to claim +// results in me owning those tokens. +#[test] +fn test_claims() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(Some(Duration::Height(1)), None); + + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "1")?; + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "2")?; + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "3")?; + + let claims = query_claims(&app, &module, STAKER)?; + assert_eq!(claims.nft_claims, vec![]); + + let res = claim_nfts(&mut app, &module, STAKER); + is_error!(res => "Nothing to claim"); + + unstake_nfts(&mut app, &module, STAKER, &["2"])?; + + let claims = query_claims(&app, &module, STAKER)?; + assert_eq!( + claims.nft_claims, + vec![NftClaim { + token_id: "2".to_string(), + release_at: cw_utils::Expiration::AtHeight(app.block_info().height + 1) + }] + ); + + // Claim now exists, but is not yet expired. Nothing to claim. + let res = claim_nfts(&mut app, &module, STAKER); + is_error!(res => "Nothing to claim"); + + app.update_block(next_block); + claim_nfts(&mut app, &module, STAKER)?; + + let owner = query_nft_owner(&app, &nft, "2")?; + assert_eq!(owner, STAKER.to_string()); + + Ok(()) +} + +// I can not have more than MAX_CLAIMS claims pending. +#[test] +fn test_max_claims() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(Some(Duration::Height(1)), None); + + for i in 0..MAX_CLAIMS { + let i_str = &i.to_string(); + mint_and_stake_nft(&mut app, &nft, &module, STAKER, i_str)?; + unstake_nfts(&mut app, &module, STAKER, &[i_str])?; + } + + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "a")?; + let res = unstake_nfts(&mut app, &module, STAKER, &["a"]); + is_error!(res => "Too many outstanding claims. Claim some tokens before unstaking more."); + + Ok(()) +} + +// I can list all of the currently staked NFTs for an address. +#[test] +fn test_list_staked_nfts() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(Some(Duration::Height(1)), None); + + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "1")?; + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "2")?; + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "3")?; + + let deardrie = "deardrie"; + mint_nft(&mut app, &nft, deardrie, "4")?; + mint_nft(&mut app, &nft, deardrie, "5")?; + + let nfts = query_staked_nfts(&app, &module, deardrie, None, None)?; + assert!(nfts.is_empty()); + + stake_nft(&mut app, &nft, &module, deardrie, "4")?; + stake_nft(&mut app, &nft, &module, deardrie, "5")?; + + let nfts = query_staked_nfts(&app, &module, deardrie, None, None)?; + assert_eq!(nfts, vec!["4".to_string(), "5".to_string()]); + + let nfts = query_staked_nfts(&app, &module, STAKER, Some("1".to_string()), Some(0))?; + assert!(nfts.is_empty()); + + let nfts = query_staked_nfts(&app, &module, STAKER, Some("3".to_string()), None)?; + assert!(nfts.is_empty()); + let nfts = query_staked_nfts(&app, &module, STAKER, Some("3".to_string()), Some(500))?; + assert!(nfts.is_empty()); + + let nfts = query_staked_nfts(&app, &module, STAKER, Some("1".to_string()), Some(2))?; + assert_eq!(nfts, vec!["2".to_string(), "3".to_string()]); + + unstake_nfts(&mut app, &module, STAKER, &["2"])?; + let nfts = query_staked_nfts(&app, &module, STAKER, Some("1".to_string()), Some(2))?; + assert_eq!(nfts, vec!["3".to_string()]); + + Ok(()) +} + +#[test] +fn test_info_query_works() -> anyhow::Result<()> { + let CommonTest { app, module, .. } = setup_test(None, None); + let info = query_info(&app, &module)?; + assert_eq!(info.info.version, env!("CARGO_PKG_VERSION").to_string()); + Ok(()) +} + +#[test] +fn test_dao_query_works() -> anyhow::Result<()> { + let CommonTest { app, module, .. } = setup_test(None, None); + let dao = query_dao(&app, &module)?; + assert_eq!(dao, DAO.to_string()); + Ok(()) +} + +// The owner may add and remove hooks. +#[test] +fn test_add_remove_hooks() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + add_hook(&mut app, &module, DAO, "meow")?; + remove_hook(&mut app, &module, DAO, "meow")?; + + // Minting NFT works if no hooks + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "1").unwrap(); + + // Add a hook to a fake contract called "meow" + add_hook(&mut app, &module, DAO, "meow")?; + + let hooks = query_hooks(&app, &module)?; + assert_eq!(hooks.hooks, vec!["meow".to_string()]); + + // Minting / staking now doesn't work because meow isn't a contract + // This failure means the hook is working + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "1").unwrap_err(); + + let res = add_hook(&mut app, &module, DAO, "meow"); + is_error!(res => "Given address already registered as a hook"); + + let res = remove_hook(&mut app, &module, DAO, "blue"); + is_error!(res => "Given address not registered as a hook"); + + let res = add_hook(&mut app, &module, "ekez", "evil"); + is_error!(res => "Unauthorized"); + let res = remove_hook(&mut app, &module, "ekez", "evil"); + is_error!(res => "Unauthorized"); + + Ok(()) +} + +#[test] +#[should_panic(expected = "Active threshold count must be greater than zero")] +fn test_instantiate_zero_active_threshold_count() { + setup_test( + None, + Some(ActiveThreshold::AbsoluteCount { + count: Uint128::zero(), + }), + ); +} + +#[test] +#[should_panic(expected = "Absolute count threshold cannot be greater than the total token supply")] +fn test_instantiate_invalid_active_threshold_count() { + setup_test( + None, + Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(100), + }), + ); +} + +#[test] +fn test_active_threshold_absolute_count() { + let CommonTest { + mut app, + module_id, + nft, + .. + } = setup_test(None, None); + + mint_nft(&mut app, &nft, STAKER, "1").unwrap(); + mint_nft(&mut app, &nft, STAKER, "2").unwrap(); + mint_nft(&mut app, &nft, STAKER, "3").unwrap(); + + let module = app + .instantiate_contract( + module_id, + Addr::unchecked(DAO), + &InstantiateMsg { + onft_collection: OnftCollection::Existing { + id: nft.to_string(), + }, + unstaking_duration: None, + active_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(3), + }), + }, + &[], + "onft_voting", + None, + ) + .unwrap(); + + // Get collection ID + let onft_collection_id = query_config(&app, &module).unwrap().onft_collection_id; + + // Not active as none staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(module.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake NFTs + stake_nft(&mut app, &onft_collection_id, &module, STAKER, "1").unwrap(); + stake_nft(&mut app, &onft_collection_id, &module, STAKER, "2").unwrap(); + stake_nft(&mut app, &onft_collection_id, &module, STAKER, "3").unwrap(); + + app.update_block(next_block); + + // Active as enough staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(module, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +fn test_active_threshold_percent() { + let CommonTest { + mut app, + module_id, + nft, + .. + } = setup_test(None, None); + + mint_nft(&mut app, &nft, STAKER, "1").unwrap(); + + let module = app + .instantiate_contract( + module_id, + Addr::unchecked(DAO), + &InstantiateMsg { + onft_collection: OnftCollection::Existing { + id: nft.to_string(), + }, + unstaking_duration: None, + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(20), + }), + }, + &[], + "onft_voting", + None, + ) + .unwrap(); + + // Get collection ID + let onft_collection_id = query_config(&app, &module).unwrap().onft_collection_id; + + // Not active as none staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(module.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake NFTs + stake_nft(&mut app, &onft_collection_id, &module, STAKER, "1").unwrap(); + app.update_block(next_block); + + // Active as enough staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(module, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +fn test_active_threshold_percent_rounds_up() { + let CommonTest { + mut app, + module_id, + nft, + .. + } = setup_test(None, None); + + mint_nft(&mut app, &nft, STAKER, "1").unwrap(); + mint_nft(&mut app, &nft, STAKER, "2").unwrap(); + mint_nft(&mut app, &nft, STAKER, "3").unwrap(); + mint_nft(&mut app, &nft, STAKER, "4").unwrap(); + mint_nft(&mut app, &nft, STAKER, "5").unwrap(); + + let module = app + .instantiate_contract( + module_id, + Addr::unchecked(DAO), + &InstantiateMsg { + onft_collection: OnftCollection::Existing { + id: nft.to_string(), + }, + unstaking_duration: None, + active_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(50), + }), + }, + &[], + "onft_voting", + None, + ) + .unwrap(); + + // Get collection ID + let onft_collection_id = query_config(&app, &module).unwrap().onft_collection_id; + + // Not active as none staked + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(module.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake 2 token as creator, should not be active. + stake_nft(&mut app, &onft_collection_id, &module, STAKER, "1").unwrap(); + stake_nft(&mut app, &onft_collection_id, &module, STAKER, "2").unwrap(); + + app.update_block(next_block); + + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(module.clone(), &QueryMsg::IsActive {}) + .unwrap(); + assert!(!is_active.active); + + // Stake 1 more token as creator, should now be active. + stake_nft(&mut app, &onft_collection_id, &module, STAKER, "3").unwrap(); + app.update_block(next_block); + + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(module, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +fn test_update_active_threshold() { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + mint_nft(&mut app, &nft, STAKER, "1").unwrap(); + + let resp: ActiveThresholdResponse = app + .wrap() + .query_wasm_smart(module.clone(), &QueryMsg::ActiveThreshold {}) + .unwrap(); + assert_eq!(resp.active_threshold, None); + + let msg = ExecuteMsg::UpdateActiveThreshold { + new_threshold: Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(1), + }), + }; + + // Expect failure as sender is not the DAO + app.execute_contract(Addr::unchecked("bob"), module.clone(), &msg, &[]) + .unwrap_err(); + + // Expect success as sender is the DAO + app.execute_contract(Addr::unchecked(DAO), module.clone(), &msg, &[]) + .unwrap(); + + let resp: ActiveThresholdResponse = app + .wrap() + .query_wasm_smart(module.clone(), &QueryMsg::ActiveThreshold {}) + .unwrap(); + assert_eq!( + resp.active_threshold, + Some(ActiveThreshold::AbsoluteCount { + count: Uint128::new(1) + }) + ); + + app.execute_contract( + Addr::unchecked(DAO), + module.clone(), + &ExecuteMsg::UpdateActiveThreshold { + new_threshold: Some(ActiveThreshold::Percentage { + percent: Decimal::percent(50), + }), + }, + &[], + ) + .unwrap(); + + let resp: ActiveThresholdResponse = app + .wrap() + .query_wasm_smart(module.clone(), &QueryMsg::ActiveThreshold {}) + .unwrap(); + assert_eq!( + resp.active_threshold, + Some(ActiveThreshold::Percentage { + percent: Decimal::percent(50) + }) + ); + + // remove + app.execute_contract( + Addr::unchecked(DAO), + module.clone(), + &ExecuteMsg::UpdateActiveThreshold { + new_threshold: None, + }, + &[], + ) + .unwrap(); + + let resp: ActiveThresholdResponse = app + .wrap() + .query_wasm_smart(module.clone(), &QueryMsg::ActiveThreshold {}) + .unwrap(); + assert_eq!(resp.active_threshold, None); + + // verify is active + let is_active: IsActiveResponse = app + .wrap() + .query_wasm_smart(module, &QueryMsg::IsActive {}) + .unwrap(); + assert!(is_active.active); +} + +#[test] +#[should_panic( + expected = "Active threshold percentage must be greater than 0 and not greater than 1" +)] +fn test_active_threshold_percentage_gt_100() { + setup_test( + None, + Some(ActiveThreshold::Percentage { + percent: Decimal::percent(120), + }), + ); +} + +#[test] +#[should_panic( + expected = "Active threshold percentage must be greater than 0 and not greater than 1" +)] +fn test_active_threshold_percentage_lte_0() { + setup_test( + None, + Some(ActiveThreshold::Percentage { + percent: Decimal::percent(0), + }), + ); +} + +#[test] +pub fn test_migrate_update_version() { + let mut deps = mock_dependencies(); + cw2::set_contract_version(&mut deps.storage, "my-contract", "1.0.0").unwrap(); + + migrate(deps.as_mut(), mock_env(), MigrateMsg {}).unwrap(); + let version = cw2::get_contract_version(&deps.storage).unwrap(); + assert_eq!(version.version, CONTRACT_VERSION); + assert_eq!(version.contract, CONTRACT_NAME); + + // migrate again, should do nothing + migrate(deps.as_mut(), mock_env(), MigrateMsg {}).unwrap(); + let version = cw2::get_contract_version(&deps.storage).unwrap(); + assert_eq!(version.version, CONTRACT_VERSION); + assert_eq!(version.contract, CONTRACT_NAME); +} + +/// Staking tokens has a one block delay before staked tokens are +/// reflected in voting power. Unstaking tokens has a one block delay +/// before the unstaking is reflected in voting power, yet you have +/// access to the NFT. If I immediately stake an unstaked NFT, my +/// voting power should not change. +#[test] +fn test_circular_stake() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "1")?; + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "2")?; + + app.update_block(next_block); + + let (total, voting) = query_total_and_voting_power(&app, &module, STAKER, None)?; + assert_eq!(total, Uint128::new(2)); + assert_eq!(voting, Uint128::new(2)); + + unstake_nfts(&mut app, &module, STAKER, &["1", "2"])?; + + // Unchanged, one block delay. + let (total, voting) = query_total_and_voting_power(&app, &module, STAKER, None)?; + assert_eq!(total, Uint128::new(2)); + assert_eq!(voting, Uint128::new(2)); + + stake_nft(&mut app, &nft, &module, STAKER, "1")?; + stake_nft(&mut app, &nft, &module, STAKER, "2")?; + + // Unchanged. + let (total, voting) = query_total_and_voting_power(&app, &module, STAKER, None)?; + assert_eq!(total, Uint128::new(2)); + assert_eq!(voting, Uint128::new(2)); + + app.update_block(next_block); + + // Still unchanged. + let (total, voting) = query_total_and_voting_power(&app, &module, STAKER, None)?; + assert_eq!(total, Uint128::new(2)); + assert_eq!(voting, Uint128::new(2)); + + Ok(()) +} + +/// I can immediately unstake after staking even though voting powers +/// aren't updated until one block later. Voting power does not change +/// if I do this. +#[test] +fn test_immediate_unstake() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "1")?; + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "2")?; + + unstake_nfts(&mut app, &module, STAKER, &["1", "2"])?; + + app.update_block(next_block); + + let (total, voting) = query_total_and_voting_power(&app, &module, STAKER, None)?; + assert_eq!(total, Uint128::zero()); + assert_eq!(voting, Uint128::zero()); + + Ok(()) +} + +/// I cannot prepare/stake an NFT I do not own. +#[test] +fn test_stake_unowned() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + mint_nft(&mut app, &nft, STAKER, "1")?; + + let res = stake_nft(&mut app, &nft, &module, "other", "1"); + is_error!(res => "Only an NFT's owner can prepare it to be staked"); + + app.update_block(next_block); + let voting = query_voting_power(&app, &module, STAKER, None)?; + assert_eq!(voting.power, Uint128::new(0)); + + Ok(()) +} + +/// I cannot confirm a stake before preparing it. +#[test] +fn test_stake_unprepared() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + mint_nft(&mut app, &nft, STAKER, "1")?; + + // attempt confirm without preparing + let res = confirm_stake_nft(&mut app, &module, STAKER, "1"); + is_error!(res => "NFTs must be prepared and transferred before they can be staked"); + + app.update_block(next_block); + let voting = query_voting_power(&app, &module, STAKER, None)?; + assert_eq!(voting.power, Uint128::new(0)); + + Ok(()) +} + +/// I cannot confirm a stake before preparing it and transferring NFT. +#[test] +fn test_stake_prepared_untransferred() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + mint_nft(&mut app, &nft, STAKER, "1")?; + + // prepare but don't transfer + prepare_stake_nft(&mut app, &module, STAKER, "1")?; + + // attempt confirm + let res = confirm_stake_nft(&mut app, &module, STAKER, "1"); + is_error!(res => "NFTs must be prepared and transferred before they can be staked"); + + app.update_block(next_block); + let voting = query_voting_power(&app, &module, STAKER, None)?; + assert_eq!(voting.power, Uint128::new(0)); + + Ok(()) +} + +/// I cannot confirm a stake that someone else prepared. +#[test] +fn test_stake_prepared_confirm_other_owner() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + mint_nft(&mut app, &nft, STAKER, "1")?; + + // prepare + prepare_stake_nft(&mut app, &module, STAKER, "1")?; + + // transfer to voting contract + send_nft(&mut app, &nft, "1", STAKER, module.as_str())?; + + // attempt confirm + let res = confirm_stake_nft(&mut app, &module, "other", "1"); + is_error!(res => "NFTs must be prepared and transferred before they can be staked"); + + app.update_block(next_block); + let voting = query_voting_power(&app, &module, "other", None)?; + assert_eq!(voting.power, Uint128::new(0)); + + Ok(()) +} + +/// I can override a prepared stake. +#[test] +fn test_override_prepared() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + mint_nft(&mut app, &nft, STAKER, "1")?; + + // prepare + prepare_stake_nft(&mut app, &module, STAKER, "1")?; + + // transfer to someone else + send_nft(&mut app, &nft, "1", STAKER, "other")?; + + // override previous owner's prepare + prepare_stake_nft(&mut app, &module, "other", "1")?; + + // transfer to voting contract + send_nft(&mut app, &nft, "1", "other", module.as_str())?; + + // confirm + confirm_stake_nft(&mut app, &module, "other", "1")?; + + app.update_block(next_block); + let voting = query_voting_power(&app, &module, "other", None)?; + assert_eq!(voting.power, Uint128::new(1)); + + Ok(()) +} + +/// I can determine what my voting power _will_ be after staking by +/// asking for my voting power one block in the future. +#[test] +fn test_query_the_future() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + mint_and_stake_nft(&mut app, &nft, &module, STAKER, "1")?; + + // Future voting power will be one under current conditions. + let voting = query_voting_power(&app, &module, STAKER, Some(app.block_info().height + 100))?; + assert_eq!(voting.power, Uint128::new(1)); + + // Current voting power is zero. + let voting = query_voting_power(&app, &module, STAKER, None)?; + assert_eq!(voting.power, Uint128::new(0)); + + unstake_nfts(&mut app, &module, STAKER, &["1"])?; + + // Future voting power is now zero. + let voting = query_voting_power(&app, &module, STAKER, Some(app.block_info().height + 100))?; + assert_eq!(voting.power, Uint128::zero()); + + Ok(()) +} + +/// I can not unstake more than one NFT in a TX in order to bypass the +/// MAX_CLAIMS limit. +#[test] +fn test_bypass_max_claims() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(Some(Duration::Height(1)), None); + let mut to_stake = vec![]; + for i in 1..(MAX_CLAIMS + 10) { + let i_str = &i.to_string(); + mint_and_stake_nft(&mut app, &nft, &module, STAKER, i_str)?; + if i < MAX_CLAIMS { + // unstake MAX_CLAMS - 1 NFTs + unstake_nfts(&mut app, &module, STAKER, &[i_str])?; + } else { + // push rest of NFT ids to vec + to_stake.push(i_str.clone()); + } + } + let binding = to_stake.iter().map(|s| s.as_str()).collect::>(); + let to_stake_slice: &[&str] = binding.as_slice(); + let res = unstake_nfts(&mut app, &module, STAKER, to_stake_slice); + is_error!(res => "Too many outstanding claims. Claim some tokens before unstaking more."); + Ok(()) +} + +/// I can cancel my own prepared stake. +#[test] +fn test_preparer_cancel_prepared_stake() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + mint_nft(&mut app, &nft, STAKER, "1")?; + + // prepare but don't transfer + prepare_stake_nft(&mut app, &module, STAKER, "1")?; + + // cancel + cancel_stake(&mut app, &module, STAKER, "1", None)?; + + // prepare and transfer + prepare_stake_nft(&mut app, &module, STAKER, "1")?; + send_nft(&mut app, &nft, "1", STAKER, module.as_str())?; + + // voting contract has the NFT + let owner = query_nft_owner(&app, &nft, "1")?; + assert_eq!(owner, module.to_string()); + + // cancel + cancel_stake(&mut app, &module, STAKER, "1", None)?; + + // original preparer has the NFT + let owner = query_nft_owner(&app, &nft, "1")?; + assert_eq!(owner, STAKER.to_string()); + + // no voting power + app.update_block(next_block); + let voting = query_voting_power(&app, &module, STAKER, None)?; + assert_eq!(voting.power, Uint128::new(0)); + + Ok(()) +} + +/// I cannot cancel someone else's prepared stake, unless I own it. +#[test] +fn test_no_cancel_other_prepared_stake() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + mint_nft(&mut app, &nft, STAKER, "1")?; + + // prepare + prepare_stake_nft(&mut app, &module, STAKER, "1")?; + + // cancel from other + let res = cancel_stake(&mut app, &module, "other", "1", None); + is_error!(res => "Only the owner or preparer can cancel a prepared stake"); + + // transfer to other + send_nft(&mut app, &nft, "1", STAKER, "other")?; + // cancel from other + cancel_stake(&mut app, &module, "other", "1", None)?; + + Ok(()) +} + +/// The DAO can cancel a prepared stake. +#[test] +fn test_dao_cancel_stake() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + mint_nft(&mut app, &nft, STAKER, "1")?; + + // prepare + prepare_stake_nft(&mut app, &module, STAKER, "1")?; + + // cancel from DAO + cancel_stake(&mut app, &module, DAO, "1", None)?; + + Ok(()) +} + +/// The DAO can cancel a prepared stake and send back to the preparer. +#[test] +fn test_dao_cancel_stake_and_return_to_preparer() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + mint_nft(&mut app, &nft, STAKER, "1")?; + + // prepare and transfer + prepare_stake_nft(&mut app, &module, STAKER, "1")?; + // transfer + send_nft(&mut app, &nft, "1", STAKER, module.as_str())?; + + // voting contract has the NFT + let owner = query_nft_owner(&app, &nft, "1")?; + assert_eq!(owner, module.to_string()); + + // cancel from DAO + cancel_stake(&mut app, &module, DAO, "1", None)?; + + // preparer has the NFT + let owner = query_nft_owner(&app, &nft, "1")?; + assert_eq!(owner, STAKER); + + Ok(()) +} + +/// The DAO can cancel a prepared stake and send to anyone. +#[test] +fn test_dao_cancel_stake_and_send_to_anyone() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + mint_nft(&mut app, &nft, STAKER, "1")?; + + // prepare and transfer + prepare_stake_nft(&mut app, &module, STAKER, "1")?; + // transfer + send_nft(&mut app, &nft, "1", STAKER, module.as_str())?; + + // voting contract has the NFT + let owner = query_nft_owner(&app, &nft, "1")?; + assert_eq!(owner, module.to_string()); + + // cancel from DAO and send to other + cancel_stake(&mut app, &module, DAO, "1", Some("other"))?; + + // other has the NFT + let owner = query_nft_owner(&app, &nft, "1")?; + assert_eq!(owner, "other"); + + // other can stake + stake_nft(&mut app, &nft, &module, "other", "1")?; + + Ok(()) +} + +/// The DAO must specify a recipient if no one prepared the NFT. +#[test] +fn test_dao_cancel_stake_must_have_recipient() -> anyhow::Result<()> { + let CommonTest { + mut app, + module, + nft, + .. + } = setup_test(None, None); + + mint_nft(&mut app, &nft, STAKER, "1")?; + + // transfer without preparing + send_nft(&mut app, &nft, "1", STAKER, module.as_str())?; + + // voting contract has the NFT + let owner = query_nft_owner(&app, &nft, "1")?; + assert_eq!(owner, module.to_string()); + + // cancel from DAO without prepared stake requires recipient + let res = cancel_stake(&mut app, &module, DAO, "1", None); + is_error!(res => "Recipient must be set when the DAO is cancelling a stake that was not prepared"); + + // cancel from DAO and send back to staker + cancel_stake(&mut app, &module, DAO, "1", Some(STAKER))?; + + // staker has the NFT + let owner = query_nft_owner(&app, &nft, "1")?; + assert_eq!(owner, STAKER); + + // staker can stake + stake_nft(&mut app, &nft, &module, STAKER, "1")?; + + Ok(()) +} diff --git a/contracts/voting/dao-voting-token-staked/Cargo.toml b/contracts/voting/dao-voting-token-staked/Cargo.toml index 8be6ca1f6..4fdee5206 100644 --- a/contracts/voting/dao-voting-token-staked/Cargo.toml +++ b/contracts/voting/dao-voting-token-staked/Cargo.toml @@ -32,7 +32,7 @@ cosmwasm_tokenfactory = ["cw-tokenfactory-issuer/cosmwasm_tokenfactory"] kujira_tokenfactory = ["cw-tokenfactory-issuer/kujira_tokenfactory"] [dependencies] -cosmwasm-std = { workspace = true, features = ["cosmwasm_1_1"] } +cosmwasm-std = { workspace = true } cosmwasm-schema = { workspace = true } cw-ownable = { workspace = true } cw-storage-plus = { workspace = true } diff --git a/packages/dao-testing/Cargo.toml b/packages/dao-testing/Cargo.toml index 51d0685b4..f75a81335 100644 --- a/packages/dao-testing/Cargo.toml +++ b/packages/dao-testing/Cargo.toml @@ -55,6 +55,7 @@ dao-voting-cw20-staked = { workspace = true } dao-voting-cw4 = { workspace = true } dao-voting-cw721-staked = { workspace = true } dao-voting-cw721-roles = { workspace = true } +dao-voting-onft-staked = { workspace = true } dao-voting-token-staked = { workspace = true } voting-v1 = { workspace = true } stake-cw20-v03 = { workspace = true } diff --git a/packages/dao-testing/src/contracts.rs b/packages/dao-testing/src/contracts.rs index a0418a48f..0da8402b3 100644 --- a/packages/dao-testing/src/contracts.rs +++ b/packages/dao-testing/src/contracts.rs @@ -117,7 +117,7 @@ pub fn native_staked_balances_voting_contract() -> Box> { Box::new(contract) } -pub fn voting_cw721_staked_contract() -> Box> { +pub fn cw721_staked_voting_contract() -> Box> { let contract = ContractWrapper::new( dao_voting_cw721_staked::contract::execute, dao_voting_cw721_staked::contract::instantiate, @@ -127,6 +127,15 @@ pub fn voting_cw721_staked_contract() -> Box> { Box::new(contract) } +pub fn onft_staked_voting_contract() -> Box> { + let contract = ContractWrapper::new( + dao_voting_onft_staked::contract::execute, + dao_voting_onft_staked::contract::instantiate, + dao_voting_onft_staked::contract::query, + ); + Box::new(contract) +} + pub fn dao_dao_contract() -> Box> { let contract = ContractWrapper::new( dao_dao_core::contract::execute,